From 13652a74b67d1afcc352d46a0df36eba5f56066e Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 10 Sep 2026 12:13:02 -0400 Subject: [PATCH] Add username card to onboarding. --- .../securesms/keyvalue/OnboardingValues.java | 28 ++- .../megaphone/OnboardingMegaphone.kt | 14 +- .../ConnectWithUsernamesDialogFragment.kt | 191 ++++++++++++++++++ .../res/drawable/discoverable_48_color.xml | 24 +++ .../res/values/onboarding_card_colors.xml | 1 + app/src/main/res/values/strings.xml | 22 ++ 6 files changed, 271 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/profiles/username/ConnectWithUsernamesDialogFragment.kt create mode 100644 app/src/main/res/drawable/discoverable_48_color.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/OnboardingValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/OnboardingValues.java index 2560cb959a..b375c80d10 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/OnboardingValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/OnboardingValues.java @@ -9,10 +9,11 @@ import java.util.List; public final class OnboardingValues extends SignalStoreValues { - private static final String SHOW_NEW_GROUP = "onboarding.new_group"; - private static final String SHOW_INVITE_FRIENDS = "onboarding.invite_friends"; - private static final String SHOW_APPEARANCE = "onboarding.appearance"; - private static final String SHOW_ADD_PHOTO = "onboarding.add_photo"; + private static final String SHOW_NEW_GROUP = "onboarding.new_group"; + private static final String SHOW_INVITE_FRIENDS = "onboarding.invite_friends"; + private static final String SHOW_APPEARANCE = "onboarding.appearance"; + private static final String SHOW_ADD_PHOTO = "onboarding.add_photo"; + private static final String SHOW_SET_UP_USERNAME = "onboarding.set_up_username"; OnboardingValues(@NonNull KeyValueStore store) { super(store); @@ -24,6 +25,7 @@ public final class OnboardingValues extends SignalStoreValues { putBoolean(SHOW_INVITE_FRIENDS, true); putBoolean(SHOW_APPEARANCE, true); putBoolean(SHOW_ADD_PHOTO, true); + putBoolean(SHOW_SET_UP_USERNAME, true); } @Override @@ -36,13 +38,15 @@ public final class OnboardingValues extends SignalStoreValues { setShowInviteFriends(false); setShowAppearance(false); setShowAddPhoto(false); + setShowSetUpUsername(false); } public boolean hasOnboarding(@NonNull Context context) { - return shouldShowNewGroup() || - shouldShowInviteFriends() || - shouldShowAppearance() || - shouldShowAddPhoto(); + return shouldShowNewGroup() || + shouldShowInviteFriends() || + shouldShowAppearance() || + shouldShowAddPhoto() || + shouldShowSetUpUsername(); } public void setShowNewGroup(boolean value) { @@ -76,4 +80,12 @@ public final class OnboardingValues extends SignalStoreValues { public boolean shouldShowAddPhoto() { return getBoolean(SHOW_ADD_PHOTO, false); } + + public void setShowSetUpUsername(boolean value) { + putBoolean(SHOW_SET_UP_USERNAME, value); + } + + public boolean shouldShowSetUpUsername() { + return getBoolean(SHOW_SET_UP_USERNAME, false); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/OnboardingMegaphone.kt b/app/src/main/java/org/thoughtcrime/securesms/megaphone/OnboardingMegaphone.kt index 47efb43841..801d227051 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/OnboardingMegaphone.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/OnboardingMegaphone.kt @@ -60,6 +60,7 @@ import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.main.EmptyMegaphoneActionController import org.thoughtcrime.securesms.profiles.manage.EditProfileActivity +import org.thoughtcrime.securesms.profiles.username.ConnectWithUsernamesDialogFragment import org.thoughtcrime.securesms.wallpaper.ChatWallpaperActivity import org.signal.core.ui.R as CoreUiR @@ -226,6 +227,11 @@ enum class OnboardingListItem( @DrawableRes val icon: Int, @ColorRes val cardColor: Int ) { + SET_UP_USERNAME( + title = R.string.Megaphones_set_up_username, + icon = CoreUiR.drawable.symbol_at_24, + cardColor = R.color.onboarding_background_5 + ), GROUP( title = R.string.Megaphones_new_group, icon = R.drawable.symbol_group_24, @@ -291,6 +297,7 @@ abstract class OnboardingState private constructor( */ private object Preview : OnboardingState( initialState = DisplayState( + shouldShowSetUpUsername = true, shouldShowNewGroup = true, shouldShowInviteFriends = true, shouldShowAddPhoto = true, @@ -300,6 +307,7 @@ abstract class OnboardingState private constructor( ) { override fun onItemCloseClick(onboardingListItem: OnboardingListItem) { displayState = when (onboardingListItem) { + OnboardingListItem.SET_UP_USERNAME -> displayState.copy(shouldShowSetUpUsername = false) OnboardingListItem.GROUP -> displayState.copy(shouldShowNewGroup = false) OnboardingListItem.INVITE -> displayState.copy(shouldShowInviteFriends = false) OnboardingListItem.ADD_PHOTO -> displayState.copy(shouldShowAddPhoto = false) @@ -316,6 +324,7 @@ abstract class OnboardingState private constructor( private class Real(megaphoneActionController: MegaphoneActionController) : OnboardingState(megaphoneActionController = megaphoneActionController) { override fun onItemCloseClick(onboardingListItem: OnboardingListItem) { when (onboardingListItem) { + OnboardingListItem.SET_UP_USERNAME -> SignalStore.onboarding.setShowSetUpUsername(false) OnboardingListItem.GROUP -> SignalStore.onboarding.setShowNewGroup(false) OnboardingListItem.INVITE -> SignalStore.onboarding.setShowInviteFriends(false) OnboardingListItem.ADD_PHOTO -> SignalStore.onboarding.setShowAddPhoto(false) @@ -331,6 +340,7 @@ abstract class OnboardingState private constructor( override fun onItemActionClick(onboardingListItem: OnboardingListItem) { when (onboardingListItem) { + OnboardingListItem.SET_UP_USERNAME -> megaphoneActionController.onMegaphoneDialogFragmentRequested(ConnectWithUsernamesDialogFragment()) OnboardingListItem.GROUP -> megaphoneActionController.onMegaphoneNavigationRequested(CreateGroupActivity.createIntent(megaphoneActionController.megaphoneActivity)) OnboardingListItem.INVITE -> megaphoneActionController.onMegaphoneNavigationRequested(AppSettingsActivity.invite(megaphoneActionController.megaphoneActivity)) OnboardingListItem.ADD_PHOTO -> { @@ -355,15 +365,17 @@ abstract class OnboardingState private constructor( * Simple display state, driven by [SignalStore] by default. */ data class DisplayState( + private val shouldShowSetUpUsername: Boolean = SignalStore.onboarding.shouldShowSetUpUsername() && SignalStore.account.isPhoneNumberless && SignalStore.account.username == null, private val shouldShowNewGroup: Boolean = SignalStore.onboarding.shouldShowNewGroup(), private val shouldShowInviteFriends: Boolean = SignalStore.onboarding.shouldShowInviteFriends(), private val shouldShowAddPhoto: Boolean = SignalStore.onboarding.shouldShowAddPhoto() && !SignalStore.misc.hasEverHadAnAvatar, private val shouldShowAppearance: Boolean = SignalStore.onboarding.shouldShowAppearance() ) { - fun hasNoVisibleContent(): Boolean = !(shouldShowNewGroup || shouldShowInviteFriends || shouldShowAddPhoto || shouldShowAppearance) + fun hasNoVisibleContent(): Boolean = !(shouldShowSetUpUsername || shouldShowNewGroup || shouldShowInviteFriends || shouldShowAddPhoto || shouldShowAppearance) fun shouldDisplayListItem(onboardingListItem: OnboardingListItem): Boolean { return when (onboardingListItem) { + OnboardingListItem.SET_UP_USERNAME -> shouldShowSetUpUsername OnboardingListItem.GROUP -> shouldShowNewGroup OnboardingListItem.INVITE -> shouldShowInviteFriends OnboardingListItem.ADD_PHOTO -> shouldShowAddPhoto diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/username/ConnectWithUsernamesDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/profiles/username/ConnectWithUsernamesDialogFragment.kt new file mode 100644 index 0000000000..41b1c09b52 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/username/ConnectWithUsernamesDialogFragment.kt @@ -0,0 +1,191 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.profiles.username + +import android.app.Dialog +import android.os.Bundle +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.signal.core.ui.compose.Buttons +import org.signal.core.ui.compose.ComposeDialogFragment +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.Scaffolds +import org.signal.core.ui.compose.SignalIcons +import org.signal.core.ui.enableEdgeToEdge +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.profiles.manage.EditProfileActivity +import org.signal.core.ui.R as CoreUiR + +/** + * Explains usernames to accounts registered without a phone number and gives them the + * opportunity to set one up now. + */ +class ConnectWithUsernamesDialogFragment : ComposeDialogFragment() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen) + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return super.onCreateDialog(savedInstanceState).apply { + window?.enableEdgeToEdge() + } + } + + @Composable + override fun DialogContent() { + ConnectWithUsernamesDialogContent( + onSetUpUsernameClick = { + startActivity(EditProfileActivity.getIntentForUsernameEdit(requireContext())) + dismissAllowingStateLoss() + }, + onNotNowClick = { dismissAllowingStateLoss() } + ) + } +} + +@Composable +private fun ConnectWithUsernamesDialogContent( + onSetUpUsernameClick: () -> Unit, + onNotNowClick: () -> Unit +) { + Scaffolds.Settings( + title = "", + onNavigationClick = onNotNowClick, + navigationIcon = SignalIcons.X.imageVector + ) { + Column(modifier = Modifier.padding(it)) { + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + ) { + Text( + text = stringResource(R.string.ConnectWithUsernamesDialogFragment__connect_with_usernames), + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensionResource(CoreUiR.dimen.gutter)) + .padding(top = 4.dp, bottom = 36.dp) + ) + + ConnectWithUsernamesRowItem( + title = stringResource(R.string.ConnectWithUsernamesDialogFragment__usernames), + description = stringResource(R.string.ConnectWithUsernamesDialogFragment__people_can_message_you_using_your_optional_username), + image = painterResource(R.drawable.usernames_48_color) + ) + + ConnectWithUsernamesRowItem( + title = stringResource(R.string.ConnectWithUsernamesDialogFragment__qr_codes_and_links), + description = stringResource(R.string.ConnectWithUsernamesDialogFragment__usernames_have_a_unique_qr_code), + image = painterResource(R.drawable.qr_codes_48_color) + ) + + ConnectWithUsernamesRowItem( + title = stringResource(R.string.ConnectWithUsernamesDialogFragment__stay_discoverable), + description = stringResource(R.string.ConnectWithUsernamesDialogFragment__without_a_phone_number_usernames_and_links), + image = painterResource(R.drawable.discoverable_48_color) + ) + } + + Buttons.LargeTonal( + onClick = onSetUpUsernameClick, + modifier = Modifier + .padding(horizontal = dimensionResource(CoreUiR.dimen.gutter)) + .padding(top = 16.dp) + .defaultMinSize(minWidth = 221.dp) + .align(Alignment.CenterHorizontally) + ) { + Text(text = stringResource(R.string.ConnectWithUsernamesDialogFragment__set_up_username)) + } + + TextButton( + onClick = onNotNowClick, + modifier = Modifier + .padding( + start = dimensionResource(CoreUiR.dimen.gutter), + end = dimensionResource(CoreUiR.dimen.gutter), + top = 8.dp, + bottom = 16.dp + ) + .defaultMinSize(minWidth = 221.dp) + .align(Alignment.CenterHorizontally) + ) { + Text(text = stringResource(R.string.ConnectWithUsernamesDialogFragment__not_now)) + } + } + } +} + +@Composable +private fun ConnectWithUsernamesRowItem( + title: String, + description: String, + image: Painter, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .padding(horizontal = dimensionResource(CoreUiR.dimen.gutter)) + .padding(bottom = 40.dp) + ) { + Image( + painter = image, + contentDescription = null, + modifier = Modifier + .padding(start = 12.dp, top = 4.dp, end = 24.dp) + .size(48.dp) + ) + + Column { + Text( + text = title, + style = MaterialTheme.typography.titleMedium + ) + + Text( + text = description, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp, end = 8.dp) + ) + } + } +} + +@DayNightPreviews +@Composable +private fun ConnectWithUsernamesDialogContentPreview() { + Previews.Preview { + ConnectWithUsernamesDialogContent( + onSetUpUsernameClick = {}, + onNotNowClick = {} + ) + } +} diff --git a/app/src/main/res/drawable/discoverable_48_color.xml b/app/src/main/res/drawable/discoverable_48_color.xml new file mode 100644 index 0000000000..6cfbaf1de0 --- /dev/null +++ b/app/src/main/res/drawable/discoverable_48_color.xml @@ -0,0 +1,24 @@ + + + + + + diff --git a/app/src/main/res/values/onboarding_card_colors.xml b/app/src/main/res/values/onboarding_card_colors.xml index 406643db21..5c3a8a51fd 100644 --- a/app/src/main/res/values/onboarding_card_colors.xml +++ b/app/src/main/res/values/onboarding_card_colors.xml @@ -4,4 +4,5 @@ #FFDEE5D6 #FFD6E5E5 #FFE5DBE7 + #FFE5E8F8 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4ca3fc0347..3f4395e1ad 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1838,6 +1838,8 @@ Invite friends Chat colors Add a profile photo + + Set up username A new device was linked to your account %1$s. @@ -3491,6 +3493,26 @@ Set up username + + + Connect with usernames + + Usernames + + People can message you using your optional username instead of a phone number. Usernames aren\'t visible on your profile. + + QR codes and links + + Usernames have a unique QR code and link you can share with friends to quickly start a chat with you. + + Stay discoverable + + Without a phone number, usernames and links are the only way others can connect with you. + + Set up username + + Not now + Play video