diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e39e89c4a3..ea15c2cbd8 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -970,6 +970,11 @@
android:windowSoftInputMode="stateVisible|adjustResize"
android:exported="false"/>
+
+
private val navController get() = Navigation.findNavController(requireView())
private val lifecycleDisposable = LifecycleDisposable()
@@ -216,6 +220,10 @@ class ConversationSettingsFragment : DSLSettingsFragment(
}
override fun bindAdapter(adapter: MappingAdapter) {
+ nicknameLauncher = registerForActivityResult(NicknameActivity.Contract()) {
+ // Intentionally left blank
+ }
+
val args = ConversationSettingsFragmentArgs.fromBundle(requireArguments())
BioTextPreference.register(adapter)
@@ -495,6 +503,21 @@ class ConversationSettingsFragment : DSLSettingsFragment(
)
}
+ if (FeatureFlags.nicknames() && state.recipient.isIndividual) {
+ clickPref(
+ title = DSLSettingsText.from(R.string.NicknameActivity__nickname),
+ icon = DSLSettingsIcon.from(R.drawable.symbol_edit_24),
+ onClick = {
+ nicknameLauncher.launch(
+ NicknameActivity.Args(
+ state.recipient.id,
+ false
+ )
+ )
+ }
+ )
+ }
+
if (!state.recipient.isReleaseNotes) {
clickPref(
title = DSLSettingsText.from(R.string.preferences__chat_color_and_wallpaper),
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt
index f073e2ce7e..948f4e7b20 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt
@@ -1739,6 +1739,20 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
}
}
+ fun setNicknameAndNote(id: RecipientId, nickname: ProfileName, note: String) {
+ val contentValues = contentValuesOf(
+ NICKNAME_GIVEN_NAME to nickname.givenName.nullIfBlank(),
+ NICKNAME_FAMILY_NAME to nickname.familyName.nullIfBlank(),
+ NICKNAME_JOINED_NAME to nickname.toString().nullIfBlank(),
+ NOTE to note.nullIfBlank()
+ )
+ if (update(id, contentValues)) {
+ rotateStorageId(id)
+ ApplicationDependencies.getDatabaseObserver().notifyRecipientChanged(id)
+ StorageSyncHelper.scheduleSyncForDataChange()
+ }
+ }
+
fun setProfileName(id: RecipientId, profileName: ProfileName) {
val contentValues = ContentValues(1).apply {
put(PROFILE_GIVEN_NAME, profileName.givenName.nullIfBlank())
diff --git a/app/src/main/java/org/thoughtcrime/securesms/nicknames/NicknameActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/nicknames/NicknameActivity.kt
new file mode 100644
index 0000000000..91cf5b561f
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/nicknames/NicknameActivity.kt
@@ -0,0 +1,500 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.nicknames
+
+import android.content.Context
+import android.content.Intent
+import android.content.res.Configuration
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.activity.result.contract.ActivityResultContract
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.ExperimentalFoundationApi
+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.lazy.LazyColumn
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+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.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.res.dimensionResource
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.core.os.bundleOf
+import org.signal.core.ui.Buttons
+import org.signal.core.ui.Dialogs
+import org.signal.core.ui.DropdownMenus
+import org.signal.core.ui.Previews
+import org.signal.core.ui.Scaffolds
+import org.signal.core.ui.TextFields
+import org.signal.core.util.getParcelableCompat
+import org.thoughtcrime.securesms.PassphraseRequiredActivity
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.avatar.AvatarImage
+import org.thoughtcrime.securesms.recipients.RecipientId
+import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
+import org.thoughtcrime.securesms.util.viewModel
+
+/**
+ * Fragment allowing a user to set a custom nickname for the given recipient.
+ */
+class NicknameActivity : PassphraseRequiredActivity(), NicknameContentCallback {
+
+ private val theme = DynamicNoActionBarTheme()
+
+ private val args: Args by lazy {
+ Args.fromBundle(intent.extras!!)
+ }
+
+ private val viewModel: NicknameViewModel by viewModel {
+ NicknameViewModel(args.recipientId)
+ }
+
+ override fun onPreCreate() {
+ theme.onCreate(this)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
+ setContent {
+ val state by viewModel.state
+
+ LaunchedEffect(state.formState) {
+ if (state.formState == NicknameState.FormState.SAVED) {
+ supportFinishAfterTransition()
+ }
+ }
+
+ NicknameContent(
+ callback = remember { this },
+ state = state,
+ focusNoteFirst = args.focusNoteFirst
+ )
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ theme.onResume(this)
+ }
+
+ override fun onNavigationClick() {
+ supportFinishAfterTransition()
+ }
+
+ override fun onSaveClick() {
+ viewModel.save()
+ }
+
+ override fun onDeleteClick() {
+ viewModel.delete()
+ }
+
+ override fun onFirstNameChanged(value: String) {
+ viewModel.onFirstNameChanged(value)
+ }
+
+ override fun onLastNameChanged(value: String) {
+ viewModel.onLastNameChanged(value)
+ }
+
+ override fun onNoteChanged(value: String) {
+ viewModel.onNoteChanged(value)
+ }
+
+ /**
+ * @param recipientId The recipient to edit the nickname and note for
+ * @param focusNoteFirst Whether default focus should be on the edit note field
+ */
+ data class Args(
+ val recipientId: RecipientId,
+ val focusNoteFirst: Boolean
+ ) {
+ fun toBundle(): Bundle {
+ return bundleOf(
+ RECIPIENT_ID to recipientId,
+ FOCUS_NOTE_FIRST to focusNoteFirst
+ )
+ }
+
+ companion object {
+ private const val RECIPIENT_ID = "recipient_id"
+ private const val FOCUS_NOTE_FIRST = "focus_note_first"
+
+ fun fromBundle(bundle: Bundle): Args {
+ return Args(
+ recipientId = bundle.getParcelableCompat(RECIPIENT_ID, RecipientId::class.java)!!,
+ focusNoteFirst = bundle.getBoolean(FOCUS_NOTE_FIRST)
+ )
+ }
+ }
+ }
+
+ /**
+ * Launches the nickname activity with the proper arguments.
+ * Doesn't return a response, but is a helpful signal to know when to refresh UI.
+ */
+ class Contract : ActivityResultContract() {
+ override fun createIntent(context: Context, input: Args): Intent {
+ return Intent(context, NicknameActivity::class.java).putExtras(input.toBundle())
+ }
+
+ override fun parseResult(resultCode: Int, intent: Intent?) = Unit
+ }
+}
+
+private interface NicknameContentCallback {
+ fun onNavigationClick()
+ fun onSaveClick()
+ fun onDeleteClick()
+ fun onFirstNameChanged(value: String)
+ fun onLastNameChanged(value: String)
+ fun onNoteChanged(value: String)
+}
+
+@Preview(name = "Light Theme", uiMode = Configuration.UI_MODE_NIGHT_NO)
+@Preview(name = "Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun NicknameContentPreview() {
+ Previews.Preview {
+ val callback = remember {
+ object : NicknameContentCallback {
+ override fun onNavigationClick() = Unit
+ override fun onSaveClick() = Unit
+ override fun onDeleteClick() = Unit
+ override fun onFirstNameChanged(value: String) = Unit
+ override fun onLastNameChanged(value: String) = Unit
+ override fun onNoteChanged(value: String) = Unit
+ }
+ }
+
+ NicknameContent(
+ callback = callback,
+ state = NicknameState(
+ isEditing = true,
+ note = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod temor incididunt ut labore et dolore magna aliqua. Ut enim ad minimu"
+ ),
+ focusNoteFirst = false
+ )
+ }
+}
+
+@Composable
+private fun NicknameContent(
+ callback: NicknameContentCallback,
+ state: NicknameState,
+ focusNoteFirst: Boolean
+) {
+ var displayDeletionDialog by remember { mutableStateOf(false) }
+
+ Scaffolds.Settings(
+ title = stringResource(id = R.string.NicknameActivity__nickname),
+ onNavigationClick = callback::onNavigationClick,
+ navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24),
+ actions = {
+ if (state.isEditing) {
+ val menuController = remember {
+ DropdownMenus.MenuController()
+ }
+
+ IconButton(onClick = { menuController.toggle() }) {
+ Icon(
+ painter = painterResource(id = R.drawable.symbol_more_vertical_24),
+ contentDescription = null
+ )
+ }
+ DropdownMenus.Menu(
+ controller = menuController
+ ) {
+ DropdownMenus.Item(
+ text = {
+ Text(text = stringResource(id = R.string.delete))
+ },
+ onClick = {
+ displayDeletionDialog = true
+ }
+ )
+ }
+ }
+ }
+ ) { paddingValues ->
+
+ val firstNameFocusRequester = remember { FocusRequester() }
+ val noteFocusRequester = remember { FocusRequester() }
+
+ Column(
+ modifier = Modifier
+ .padding(paddingValues)
+ .padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
+ ) {
+ LazyColumn(modifier = Modifier.weight(1f)) {
+ item {
+ Text(
+ text = stringResource(id = R.string.NicknameActivity__nicknames_amp_notes),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+
+ item {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 24.dp)
+ ) {
+ if (state.recipient != null) {
+ AvatarImage(recipient = state.recipient, modifier = Modifier.size(80.dp))
+ } else {
+ Spacer(modifier = Modifier.size(80.dp))
+ }
+ }
+ }
+
+ item {
+ ClearableTextField(
+ value = state.firstName,
+ hint = stringResource(id = R.string.NicknameActivity__first_name),
+ clearContentDescription = stringResource(id = R.string.NicknameActivity__clear_first_name),
+ enabled = true,
+ singleLine = true,
+ onValueChange = callback::onFirstNameChanged,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
+ modifier = Modifier
+ .focusRequester(firstNameFocusRequester)
+ .fillMaxWidth()
+ .padding(bottom = 20.dp)
+ )
+ }
+
+ item {
+ ClearableTextField(
+ value = state.lastName,
+ hint = stringResource(id = R.string.NicknameActivity__last_name),
+ clearContentDescription = stringResource(id = R.string.NicknameActivity__clear_last_name),
+ enabled = state.firstName.isNotBlank(),
+ singleLine = true,
+ onValueChange = callback::onLastNameChanged,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 20.dp)
+ )
+ }
+
+ item {
+ ClearableTextField(
+ value = state.note,
+ hint = stringResource(id = R.string.NicknameActivity__note),
+ clearContentDescription = "",
+ clearable = false,
+ enabled = true,
+ onValueChange = callback::onNoteChanged,
+ keyboardActions = KeyboardActions(onDone = {
+ callback.onSaveClick()
+ }),
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
+ charactersRemaining = state.noteCharactersRemaining,
+ modifier = Modifier
+ .focusRequester(noteFocusRequester)
+ .fillMaxWidth()
+ .padding(bottom = 20.dp)
+ )
+ }
+ }
+
+ Box(
+ contentAlignment = Alignment.CenterEnd,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 20.dp)
+ ) {
+ Buttons.LargeTonal(
+ onClick = callback::onSaveClick,
+ enabled = state.canSave
+ ) {
+ Text(
+ text = stringResource(id = R.string.NicknameActivity__save)
+ )
+ }
+ }
+ }
+
+ if (displayDeletionDialog) {
+ Dialogs.SimpleAlertDialog(
+ title = stringResource(id = R.string.NicknameActivity__delete_nickname),
+ body = stringResource(id = R.string.NicknameActivity__this_will_permanently_delete_this_nickname_and_note),
+ confirm = stringResource(id = R.string.delete),
+ dismiss = stringResource(id = android.R.string.cancel),
+ onConfirm = {
+ callback.onDeleteClick()
+ },
+ onDismiss = { displayDeletionDialog = false }
+ )
+ }
+
+ LaunchedEffect(state.hasBecomeReady) {
+ if (state.hasBecomeReady) {
+ if (focusNoteFirst) {
+ noteFocusRequester.requestFocus()
+ } else {
+ firstNameFocusRequester.requestFocus()
+ }
+ }
+ }
+ }
+}
+
+@Preview(name = "Light Theme", uiMode = Configuration.UI_MODE_NIGHT_NO)
+@Preview(name = "Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun ClearableTextFieldPreview() {
+ Previews.Preview {
+ val focusRequester = remember { FocusRequester() }
+
+ Column(modifier = Modifier.padding(16.dp)) {
+ ClearableTextField(
+ value = "",
+ hint = "Without content",
+ enabled = true,
+ onValueChange = {},
+ clearContentDescription = ""
+ )
+ Spacer(modifier = Modifier.size(16.dp))
+ ClearableTextField(
+ value = "Test",
+ hint = "With Content",
+ enabled = true,
+ onValueChange = {},
+ clearContentDescription = ""
+ )
+ Spacer(modifier = Modifier.size(16.dp))
+ ClearableTextField(
+ value = "",
+ hint = "Disabled",
+ enabled = false,
+ onValueChange = {},
+ clearContentDescription = ""
+ )
+ Spacer(modifier = Modifier.size(16.dp))
+ ClearableTextField(
+ value = "",
+ hint = "Focused",
+ enabled = true,
+ onValueChange = {},
+ modifier = Modifier.focusRequester(focusRequester),
+ clearContentDescription = ""
+ )
+ }
+
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
+ }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun ClearableTextField(
+ value: String,
+ hint: String,
+ clearContentDescription: String,
+ enabled: Boolean,
+ onValueChange: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ singleLine: Boolean = false,
+ clearable: Boolean = true,
+ charactersRemaining: Int = Int.MAX_VALUE,
+ keyboardActions: KeyboardActions = KeyboardActions.Default,
+ keyboardOptions: KeyboardOptions = KeyboardOptions.Default
+) {
+ var focused by remember { mutableStateOf(false) }
+
+ val displayCountdown = charactersRemaining <= 100
+
+ val clearButton: @Composable () -> Unit = {
+ ClearButton(
+ visible = focused,
+ onClick = { onValueChange("") },
+ contentDescription = clearContentDescription
+ )
+ }
+
+ Box(modifier = modifier) {
+ TextFields.TextField(
+ value = value,
+ onValueChange = onValueChange,
+ label = {
+ Text(text = hint)
+ },
+ enabled = enabled,
+ singleLine = singleLine,
+ keyboardActions = keyboardActions,
+ keyboardOptions = keyboardOptions,
+ modifier = Modifier
+ .fillMaxWidth()
+ .onFocusChanged { focused = it.hasFocus && clearable },
+ colors = TextFieldDefaults.colors(
+ unfocusedLabelColor = MaterialTheme.colorScheme.outline,
+ unfocusedIndicatorColor = MaterialTheme.colorScheme.outline
+ ),
+ trailingIcon = if (clearable) clearButton else null,
+ contentPadding = TextFieldDefaults.contentPaddingWithLabel(end = if (displayCountdown) 48.dp else 16.dp)
+ )
+
+ AnimatedVisibility(
+ visible = displayCountdown,
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(bottom = 10.dp, end = 12.dp)
+ ) {
+ Text(
+ text = "$charactersRemaining",
+ style = MaterialTheme.typography.bodySmall,
+ color = if (charactersRemaining <= 5) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.outline
+ )
+ }
+ }
+}
+
+@Composable
+private fun ClearButton(
+ visible: Boolean,
+ onClick: () -> Unit,
+ contentDescription: String
+) {
+ AnimatedVisibility(visible = visible) {
+ IconButton(
+ onClick = onClick
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.symbol_x_circle_fill_24),
+ contentDescription = contentDescription,
+ tint = MaterialTheme.colorScheme.outline
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/nicknames/NicknameState.kt b/app/src/main/java/org/thoughtcrime/securesms/nicknames/NicknameState.kt
new file mode 100644
index 0000000000..1fd84ea0f2
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/nicknames/NicknameState.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.nicknames
+
+import org.thoughtcrime.securesms.recipients.Recipient
+
+data class NicknameState(
+ val recipient: Recipient? = null,
+ val firstName: String = "",
+ val lastName: String = "",
+ val note: String = "",
+ val noteCharactersRemaining: Int = 0,
+ val formState: FormState = FormState.LOADING,
+ val hasBecomeReady: Boolean = false,
+ val isEditing: Boolean = false
+) {
+
+ private val isFormBlank: Boolean = firstName.isBlank() && lastName.isBlank() && note.isBlank()
+ private val hasFirstNameOrNote: Boolean = firstName.isNotBlank() || note.isNotBlank()
+ private val isFormReady: Boolean = formState == FormState.READY
+ private val isBlankFormDuringEdit: Boolean = isFormBlank && isEditing
+
+ val canSave: Boolean = isFormReady && (hasFirstNameOrNote || isBlankFormDuringEdit)
+ enum class FormState {
+ LOADING,
+ READY,
+ SAVING,
+ SAVED
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/nicknames/NicknameViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/nicknames/NicknameViewModel.kt
new file mode 100644
index 0000000000..24a83a698e
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/nicknames/NicknameViewModel.kt
@@ -0,0 +1,127 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.nicknames
+
+import androidx.annotation.MainThread
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
+import io.reactivex.rxjava3.schedulers.Schedulers
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.signal.core.util.BreakIteratorCompat
+import org.thoughtcrime.securesms.database.SignalDatabase
+import org.thoughtcrime.securesms.profiles.ProfileName
+import org.thoughtcrime.securesms.recipients.Recipient
+import org.thoughtcrime.securesms.recipients.RecipientId
+
+class NicknameViewModel(
+ private val recipientId: RecipientId
+) : ViewModel() {
+ companion object {
+ private const val NAME_MAX_LENGTH = 26
+ private const val NOTE_MAX_LENGTH = 240
+ }
+
+ private val internalState = mutableStateOf(NicknameState())
+ private val iteratorCompat = BreakIteratorCompat.getInstance()
+
+ val state: MutableState = internalState
+
+ private val recipientDisposable = Recipient.observable(recipientId)
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe { recipient ->
+ internalState.value = if (state.value.formState == NicknameState.FormState.LOADING) {
+ val noteLength = iteratorCompat.run {
+ setText(recipient.note ?: "")
+ countBreaks()
+ }
+
+ NicknameState(
+ recipient = recipient,
+ firstName = recipient.nickname.givenName,
+ lastName = recipient.nickname.familyName,
+ note = recipient.note ?: "",
+ noteCharactersRemaining = NOTE_MAX_LENGTH - noteLength,
+ formState = NicknameState.FormState.READY,
+ hasBecomeReady = true,
+ isEditing = !recipient.nickname.isEmpty
+ )
+ } else {
+ state.value.copy(recipient = recipient)
+ }
+ }
+
+ override fun onCleared() {
+ recipientDisposable.dispose()
+ }
+
+ @MainThread
+ fun onFirstNameChanged(value: String) {
+ iteratorCompat.setText(value)
+ internalState.value = state.value.copy(firstName = iteratorCompat.take(NAME_MAX_LENGTH).toString())
+ }
+
+ @MainThread
+ fun onLastNameChanged(value: String) {
+ iteratorCompat.setText(value)
+ internalState.value = state.value.copy(lastName = iteratorCompat.take(NAME_MAX_LENGTH).toString())
+ }
+
+ @MainThread
+ fun onNoteChanged(value: String) {
+ iteratorCompat.setText(value)
+ val trimmed = iteratorCompat.take(NOTE_MAX_LENGTH)
+ val count = iteratorCompat.run {
+ setText(trimmed)
+ countBreaks()
+ }
+
+ internalState.value = state.value.copy(
+ note = iteratorCompat.take(NOTE_MAX_LENGTH).toString(),
+ noteCharactersRemaining = NOTE_MAX_LENGTH - count
+ )
+ }
+
+ @MainThread
+ fun delete() {
+ viewModelScope.launch {
+ internalState.value = state.value.copy(formState = NicknameState.FormState.SAVING)
+
+ withContext(Dispatchers.IO) {
+ SignalDatabase.recipients.setNicknameAndNote(
+ recipientId,
+ ProfileName.EMPTY,
+ ""
+ )
+ }
+
+ internalState.value = state.value.copy(formState = NicknameState.FormState.SAVED)
+ }
+ }
+
+ @MainThread
+ fun save() {
+ viewModelScope.launch {
+ val stateSnapshot = state.value.copy(formState = NicknameState.FormState.SAVING)
+ internalState.value = stateSnapshot
+
+ withContext(Dispatchers.IO) {
+ SignalDatabase.recipients.setNicknameAndNote(
+ recipientId,
+ ProfileName.fromParts(stateSnapshot.firstName, stateSnapshot.lastName),
+ stateSnapshot.note
+ )
+ }
+
+ internalState.value = state.value.copy(formState = NicknameState.FormState.SAVED)
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/nicknames/ViewNoteSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/nicknames/ViewNoteSheet.kt
new file mode 100644
index 0000000000..11ddfbff7a
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/nicknames/ViewNoteSheet.kt
@@ -0,0 +1,169 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.nicknames
+
+import android.os.Bundle
+import android.text.util.Linkify
+import androidx.activity.result.ActivityResultLauncher
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.CenterAlignedTopAppBar
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalInspectionMode
+import androidx.compose.ui.res.dimensionResource
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.core.os.bundleOf
+import androidx.core.text.method.LinkMovementMethodCompat
+import androidx.core.text.util.LinkifyCompat
+import org.signal.core.ui.BottomSheets
+import org.signal.core.ui.Previews
+import org.signal.core.util.getParcelableCompat
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.components.emoji.EmojiTextView
+import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
+import org.thoughtcrime.securesms.recipients.RecipientId
+import org.thoughtcrime.securesms.util.viewModel
+
+/**
+ * Allows user to view the full note for a given recipient.
+ */
+class ViewNoteSheet : ComposeBottomSheetDialogFragment() {
+
+ companion object {
+
+ private const val RECIPIENT_ID = "recipient_id"
+
+ @JvmStatic
+ fun create(recipientId: RecipientId): ViewNoteSheet {
+ return ViewNoteSheet().apply {
+ arguments = bundleOf(
+ RECIPIENT_ID to recipientId
+ )
+ }
+ }
+ }
+
+ private val recipientId: RecipientId by lazy {
+ requireArguments().getParcelableCompat(RECIPIENT_ID, RecipientId::class.java)!!
+ }
+
+ private val viewModel: ViewNoteSheetViewModel by viewModel {
+ ViewNoteSheetViewModel(recipientId)
+ }
+
+ private lateinit var editNoteLauncher: ActivityResultLauncher
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ editNoteLauncher = registerForActivityResult(NicknameActivity.Contract()) {}
+ }
+
+ @Composable
+ override fun SheetContent() {
+ val note by remember { viewModel.note }
+
+ ViewNoteBottomSheetContent(
+ onEditNoteClick = this::onEditNoteClick,
+ note = note
+ )
+ }
+
+ private fun onEditNoteClick() {
+ editNoteLauncher.launch(
+ NicknameActivity.Args(
+ recipientId = recipientId,
+ focusNoteFirst = true
+ )
+ )
+
+ dismissAllowingStateLoss()
+ }
+}
+
+@Preview
+@Composable
+private fun ViewNoteBottomSheetContentPreview() {
+ Previews.Preview {
+ ViewNoteBottomSheetContent(
+ onEditNoteClick = {},
+ note = "Lorem ipsum dolor sit amet\n\nWebsite: https://example.com"
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ViewNoteBottomSheetContent(
+ onEditNoteClick: () -> Unit,
+ note: String
+) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
+ ) {
+ BottomSheets.Handle()
+
+ CenterAlignedTopAppBar(
+ title = {
+ Text(
+ text = stringResource(id = R.string.ViewNoteSheet__note)
+ )
+ },
+ actions = {
+ IconButton(onClick = onEditNoteClick) {
+ Icon(
+ painter = painterResource(id = R.drawable.symbol_edit_24),
+ contentDescription = stringResource(id = R.string.ViewNoteSheet__edit_note)
+ )
+ }
+ },
+ colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
+ containerColor = Color.Transparent,
+ scrolledContainerColor = Color.Transparent
+ )
+ )
+
+ val mask = if (LocalInspectionMode.current) {
+ Linkify.WEB_URLS
+ } else {
+ Linkify.WEB_URLS or Linkify.EMAIL_ADDRESSES or Linkify.PHONE_NUMBERS
+ }
+
+ AndroidView(
+ factory = { context ->
+ val view = EmojiTextView(context)
+
+ @Suppress("DEPRECATION")
+ view.setTextAppearance(context, R.style.Signal_Text_BodyLarge)
+ view.movementMethod = LinkMovementMethodCompat.getInstance()
+
+ view
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 48.dp)
+ ) {
+ it.text = note
+
+ LinkifyCompat.addLinks(it, mask)
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/nicknames/ViewNoteSheetViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/nicknames/ViewNoteSheetViewModel.kt
new file mode 100644
index 0000000000..517d6f96d1
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/nicknames/ViewNoteSheetViewModel.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.nicknames
+
+import androidx.compose.runtime.State
+import androidx.compose.runtime.mutableStateOf
+import androidx.lifecycle.ViewModel
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
+import io.reactivex.rxjava3.kotlin.subscribeBy
+import io.reactivex.rxjava3.schedulers.Schedulers
+import org.thoughtcrime.securesms.recipients.Recipient
+import org.thoughtcrime.securesms.recipients.RecipientId
+
+class ViewNoteSheetViewModel(
+ recipientId: RecipientId
+) : ViewModel() {
+ private val internalNote = mutableStateOf("")
+ val note: State = internalNote
+
+ private val recipientDisposable = Recipient.observable(recipientId)
+ .map { it.note ?: "" }
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribeBy { internalNote.value = it }
+
+ override fun onCleared() {
+ recipientDisposable.dispose()
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java
index 6570a5d880..8b82dc4fce 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java
@@ -505,6 +505,10 @@ public class Recipient {
return contactUri;
}
+ public @Nullable String getNote() {
+ return note;
+ }
+
public @Nullable String getGroupName(@NonNull Context context) {
if (groupId != null && Util.isEmpty(this.groupName)) {
RecipientId selfId = ApplicationDependencies.getRecipientCache().getSelfId();
diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/about/AboutSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/about/AboutSheet.kt
index 4db9e45db2..ac640da248 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/about/AboutSheet.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/about/AboutSheet.kt
@@ -10,6 +10,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -27,9 +29,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
@@ -44,6 +48,7 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.AvatarImage
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
+import org.thoughtcrime.securesms.nicknames.ViewNoteSheet
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
@@ -101,10 +106,12 @@ class AboutSheet : ComposeBottomSheetDialogFragment() {
},
groupsInCommon = groupsInCommonCount,
profileSharing = recipient.get().isProfileSharing,
- systemContact = recipient.get().isSystemContact
+ systemContact = recipient.get().isSystemContact,
+ note = recipient.get().note ?: ""
),
onClickSignalConnections = this::openSignalConnectionsSheet,
- onAvatarClicked = this::openProfilePhotoViewer
+ onAvatarClicked = this::openProfilePhotoViewer,
+ onNoteClicked = this::openNoteSheet
)
}
}
@@ -117,6 +124,11 @@ class AboutSheet : ComposeBottomSheetDialogFragment() {
private fun openProfilePhotoViewer() {
startActivity(AvatarPreviewActivity.intentFromRecipientId(requireContext(), recipientId))
}
+
+ private fun openNoteSheet() {
+ dismiss()
+ ViewNoteSheet.create(recipientId).show(parentFragmentManager, null)
+ }
}
private data class AboutModel(
@@ -130,14 +142,16 @@ private data class AboutModel(
val formattedE164: String?,
val profileSharing: Boolean,
val systemContact: Boolean,
- val groupsInCommon: Int
+ val groupsInCommon: Int,
+ val note: String
)
@Composable
private fun Content(
model: AboutModel,
onClickSignalConnections: () -> Unit,
- onAvatarClicked: () -> Unit
+ onAvatarClicked: () -> Unit,
+ onNoteClicked: () -> Unit
) {
Box(
contentAlignment = Alignment.Center,
@@ -180,12 +194,15 @@ private fun Content(
)
if (model.about.isNotNullOrBlank()) {
+ val textColor = LocalContentColor.current
+
AboutRow(
startIcon = painterResource(R.drawable.symbol_edit_24),
text = {
Row {
AndroidView(factory = ::EmojiTextView) {
it.text = model.about
+ it.setTextColor(textColor.toArgb())
TextViewCompat.setTextAppearance(it, R.style.Signal_Text_BodyLarge)
}
@@ -255,6 +272,16 @@ private fun Content(
modifier = Modifier.fillMaxWidth()
)
+ if (model.note.isNotBlank()) {
+ AboutRow(
+ startIcon = painterResource(id = R.drawable.symbol_note_light_24),
+ text = model.note,
+ modifier = Modifier.fillMaxWidth(),
+ endIcon = painterResource(id = R.drawable.symbol_chevron_right_compact_bold_16),
+ onClick = onNoteClicked
+ )
+ }
+
Spacer(modifier = Modifier.size(26.dp))
}
}
@@ -272,7 +299,10 @@ private fun AboutRow(
text = {
Text(
text = text,
- style = MaterialTheme.typography.bodyLarge
+ style = MaterialTheme.typography.bodyLarge,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f, false)
)
},
modifier = modifier,
@@ -284,7 +314,7 @@ private fun AboutRow(
@Composable
private fun AboutRow(
startIcon: Painter,
- text: @Composable () -> Unit,
+ text: @Composable RowScope.() -> Unit,
modifier: Modifier = Modifier,
endIcon: Painter? = null,
onClick: (() -> Unit)? = null
@@ -346,10 +376,12 @@ private fun ContentPreviewDefault() {
formattedE164 = "(123) 456-7890",
profileSharing = true,
systemContact = true,
- groupsInCommon = 0
+ groupsInCommon = 0,
+ note = "GET ME SPIDERMAN BEFORE I BLOW A DANG GASKET"
),
onClickSignalConnections = {},
- onAvatarClicked = {}
+ onAvatarClicked = {},
+ onNoteClicked = {}
)
}
}
@@ -373,10 +405,12 @@ private fun ContentPreviewInContactsNotProfileSharing() {
formattedE164 = null,
profileSharing = false,
systemContact = true,
- groupsInCommon = 3
+ groupsInCommon = 3,
+ note = "GET ME SPIDER MAN"
),
onClickSignalConnections = {},
- onAvatarClicked = {}
+ onAvatarClicked = {},
+ onNoteClicked = {}
)
}
}
@@ -400,10 +434,12 @@ private fun ContentPreviewGroupsInCommonNoE164() {
formattedE164 = null,
profileSharing = true,
systemContact = false,
- groupsInCommon = 3
+ groupsInCommon = 3,
+ note = "GET ME SPIDERMAN"
),
onClickSignalConnections = {},
- onAvatarClicked = {}
+ onAvatarClicked = {},
+ onNoteClicked = {}
)
}
}
@@ -427,10 +463,12 @@ private fun ContentPreviewNotAConnection() {
formattedE164 = null,
profileSharing = false,
systemContact = false,
- groupsInCommon = 3
+ groupsInCommon = 3,
+ note = "GET ME SPIDERMAN"
),
onClickSignalConnections = {},
- onAvatarClicked = {}
+ onAvatarClicked = {},
+ onNoteClicked = {}
)
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java
index 3d52cc9865..8a88ca232b 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java
@@ -17,6 +17,7 @@ import android.widget.ProgressBar;
import android.widget.TextView;
import android.widget.Toast;
+import androidx.activity.result.ActivityResultLauncher;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
@@ -39,6 +40,7 @@ import org.thoughtcrime.securesms.contacts.avatars.FallbackPhoto80dp;
import org.thoughtcrime.securesms.fonts.SignalSymbols;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
+import org.thoughtcrime.securesms.nicknames.NicknameActivity;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientExporter;
import org.thoughtcrime.securesms.recipients.RecipientId;
@@ -46,6 +48,7 @@ import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.recipients.ui.about.AboutSheet;
import org.thoughtcrime.securesms.util.BottomSheetUtil;
import org.thoughtcrime.securesms.util.ContextUtil;
+import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.SpanUtil;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.Util;
@@ -74,6 +77,7 @@ public final class RecipientBottomSheetDialogFragment extends BottomSheetDialogF
private AvatarView avatar;
private TextView fullName;
private TextView about;
+ private TextView nickname;
private TextView blockButton;
private TextView unblockButton;
private TextView addContactButton;
@@ -92,6 +96,8 @@ public final class RecipientBottomSheetDialogFragment extends BottomSheetDialogF
private ButtonStripPreference.ViewHolder buttonStripViewHolder;
+ private ActivityResultLauncher nicknameLauncher;
+
public static void show(FragmentManager fragmentManager, @NonNull RecipientId recipientId, @Nullable GroupId groupId) {
Recipient recipient = Recipient.resolved(recipientId);
if (recipient.isSelf()) {
@@ -127,6 +133,7 @@ public final class RecipientBottomSheetDialogFragment extends BottomSheetDialogF
avatar = view.findViewById(R.id.rbs_recipient_avatar);
fullName = view.findViewById(R.id.rbs_full_name);
about = view.findViewById(R.id.rbs_about);
+ nickname = view.findViewById(R.id.rbs_nickname_button);
blockButton = view.findViewById(R.id.rbs_block_button);
unblockButton = view.findViewById(R.id.rbs_unblock_button);
addContactButton = view.findViewById(R.id.rbs_add_contact_button);
@@ -150,6 +157,8 @@ public final class RecipientBottomSheetDialogFragment extends BottomSheetDialogF
public void onViewCreated(@NonNull View fragmentView, @Nullable Bundle savedInstanceState) {
super.onViewCreated(fragmentView, savedInstanceState);
+ nicknameLauncher = registerForActivityResult(new NicknameActivity.Contract(), (b) -> {});
+
Bundle arguments = requireArguments();
RecipientId recipientId = RecipientId.from(Objects.requireNonNull(arguments.getString(ARGS_RECIPIENT_ID)));
GroupId groupId = GroupId.parseNullableOrThrow(arguments.getString(ARGS_GROUP_ID));
@@ -214,6 +223,16 @@ public final class RecipientBottomSheetDialogFragment extends BottomSheetDialogF
dismiss();
AboutSheet.create(recipient).show(getParentFragmentManager(), null);
});
+
+ if (FeatureFlags.nicknames() && groupId != null) {
+ nickname.setVisibility(View.VISIBLE);
+ nickname.setOnClickListener(v -> {
+ nicknameLauncher.launch(new NicknameActivity.Args(
+ recipientId,
+ false
+ ));
+ });
+ }
}
String aboutText = recipient.getCombinedAboutAndEmoji();
diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java
index 3d01d1637b..e8e332e793 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java
@@ -127,6 +127,7 @@ public final class FeatureFlags {
private static final String RX_MESSAGE_SEND = "android.rxMessageSend";
private static final String LINKED_DEVICE_LIFESPAN_SECONDS = "android.linkedDeviceLifespanSeconds";
private static final String MESSAGE_BACKUPS = "android.messageBackups";
+ private static final String NICKNAMES = "android.nicknames";
/**
* We will only store remote values for flags in this set. If you want a flag to be controllable
@@ -205,7 +206,8 @@ public final class FeatureFlags {
PREKEY_FORCE_REFRESH_INTERVAL,
CDSI_LIBSIGNAL_NET,
RX_MESSAGE_SEND,
- LINKED_DEVICE_LIFESPAN_SECONDS
+ LINKED_DEVICE_LIFESPAN_SECONDS,
+ NICKNAMES
);
@VisibleForTesting
@@ -740,6 +742,11 @@ public final class FeatureFlags {
return getBoolean(MESSAGE_BACKUPS, false);
}
+ /** Whether or not the nicknames feature is available */
+ public static boolean nicknames() {
+ return getBoolean(NICKNAMES, false);
+ }
+
/** Only for rendering debug info. */
public static synchronized @NonNull Map getMemoryValues() {
return new TreeMap<>(REMOTE_VALUES);
diff --git a/app/src/main/res/drawable/symbol_x_circle_fill_24.xml b/app/src/main/res/drawable/symbol_x_circle_fill_24.xml
new file mode 100644
index 0000000000..88c9ea0d90
--- /dev/null
+++ b/app/src/main/res/drawable/symbol_x_circle_fill_24.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/layout/recipient_bottom_sheet.xml b/app/src/main/res/layout/recipient_bottom_sheet.xml
index b6097e0369..4d81412555 100644
--- a/app/src/main/res/layout/recipient_bottom_sheet.xml
+++ b/app/src/main/res/layout/recipient_bottom_sheet.xml
@@ -119,6 +119,23 @@
+
+
Got it
+
+
+ Nickname
+
+ Nicknames & notes are stored using Signal\'s end-to-end encrypted storage service. They are only visible to you.
+
+ First name
+
+ Clear first name
+
+ Last name
+
+ Clear last name
+
+ Note
+
+ Save
+
+ Delete nickname?
+
+ This will permanently delete this nickname and note.
+
+
+
+ Note
+
+ Edit note
+
diff --git a/core-ui/src/main/java/org/signal/core/ui/DropdownMenus.kt b/core-ui/src/main/java/org/signal/core/ui/DropdownMenus.kt
new file mode 100644
index 0000000000..75ced668de
--- /dev/null
+++ b/core-ui/src/main/java/org/signal/core/ui/DropdownMenus.kt
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.core.ui
+
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.dimensionResource
+import androidx.compose.ui.unit.DpOffset
+import androidx.compose.ui.unit.dp
+import org.signal.core.ui.copied.androidx.compose.material3.DropdownMenu
+
+/**
+ * Properly styled dropdown menus and items.
+ */
+object DropdownMenus {
+ /**
+ * Properly styled dropdown menu
+ */
+ @Composable
+ fun Menu(
+ controller: MenuController = remember { MenuController() },
+ modifier: Modifier = Modifier,
+ content: @Composable ColumnScope.(MenuController) -> Unit
+ ) {
+ MaterialTheme(shapes = MaterialTheme.shapes.copy(extraSmall = RoundedCornerShape(18.dp))) {
+ DropdownMenu(
+ expanded = controller.isShown(),
+ onDismissRequest = controller::hide,
+ offset = DpOffset(
+ x = dimensionResource(id = R.dimen.core_ui__gutter),
+ y = 0.dp
+ ),
+ content = { content(controller) },
+ modifier = modifier
+ )
+ }
+ }
+
+ /**
+ * Properly styled dropdown menu item
+ */
+ @Composable
+ fun Item(
+ contentPadding: PaddingValues = PaddingValues(horizontal = 16.dp),
+ text: @Composable () -> Unit,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier
+ ) {
+ DropdownMenuItem(
+ contentPadding = contentPadding,
+ text = text,
+ onClick = onClick,
+ modifier = modifier
+ )
+ }
+
+ /**
+ * Menu controller to hold menu display state and allow other components
+ * to show and hide it.
+ */
+ class MenuController {
+ private var isMenuShown by mutableStateOf(false)
+
+ fun show() {
+ isMenuShown = true
+ }
+
+ fun hide() {
+ isMenuShown = false
+ }
+
+ fun toggle() {
+ if (isShown()) {
+ hide()
+ } else {
+ show()
+ }
+ }
+
+ fun isShown() = isMenuShown
+ }
+}
diff --git a/core-ui/src/main/java/org/signal/core/ui/Scaffolds.kt b/core-ui/src/main/java/org/signal/core/ui/Scaffolds.kt
index b4beaf172d..1baf583e57 100644
--- a/core-ui/src/main/java/org/signal/core/ui/Scaffolds.kt
+++ b/core-ui/src/main/java/org/signal/core/ui/Scaffolds.kt
@@ -2,8 +2,11 @@ package org.signal.core.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -40,6 +43,7 @@ object Scaffolds {
Text(text = title, style = MaterialTheme.typography.titleLarge)
},
snackbarHost: @Composable () -> Unit = {},
+ actions: @Composable RowScope.() -> Unit = {},
content: @Composable (PaddingValues) -> Unit
) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
@@ -53,7 +57,8 @@ object Scaffolds {
scrollBehavior,
onNavigationClick,
navigationIconPainter,
- navigationContentDescription
+ navigationContentDescription,
+ actions
)
},
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
@@ -68,7 +73,8 @@ object Scaffolds {
scrollBehavior: TopAppBarScrollBehavior,
onNavigationClick: () -> Unit,
navigationIconPainter: Painter,
- navigationContentDescription: String?
+ navigationContentDescription: String?,
+ actions: @Composable RowScope.() -> Unit
) {
TopAppBar(
title = {
@@ -88,7 +94,8 @@ object Scaffolds {
scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.topAppBarColors(
scrolledContainerColor = SignalTheme.colors.colorSurface2
- )
+ ),
+ actions = actions
)
}
}
@@ -100,7 +107,12 @@ private fun SettingsScaffoldPreview() {
Scaffolds.Settings(
"Settings Scaffold",
onNavigationClick = {},
- navigationIconPainter = ColorPainter(Color.Black)
+ navigationIconPainter = ColorPainter(Color.Black),
+ actions = {
+ IconButton(onClick = {}) {
+ Icon(Icons.Default.Settings, contentDescription = null)
+ }
+ }
) { paddingValues ->
Box(
contentAlignment = Alignment.Center,
diff --git a/core-ui/src/main/java/org/signal/core/ui/TextFields.kt b/core-ui/src/main/java/org/signal/core/ui/TextFields.kt
index c889d0298f..fb6ff99067 100644
--- a/core-ui/src/main/java/org/signal/core/ui/TextFields.kt
+++ b/core-ui/src/main/java/org/signal/core/ui/TextFields.kt
@@ -5,9 +5,12 @@
package org.signal.core.ui
+import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.relocation.BringIntoViewRequester
+import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
@@ -21,22 +24,34 @@ import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.takeOrElse
+import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.launch
object TextFields {
/**
- * This is intended to replicate what TextField exposes but allows us to set our own content padding.
+ * This is intended to replicate what TextField exposes but allows us to set our own content padding as
+ * well as resolving the auto-scroll to cursor position issue.
+ *
* Prefer the base TextField where possible.
*/
- @OptIn(ExperimentalMaterial3Api::class)
+ @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun TextField(
value: String,
@@ -76,15 +91,57 @@ object TextFields {
val mergedTextStyle = textStyle.merge(TextStyle(color = textColor))
val cursorColor = rememberUpdatedState(newValue = if (isError) MaterialTheme.colorScheme.error else textColor)
+ // Borrowed from BasicTextField, all this helps reduce recompositions.
+ var lastTextValue by remember(value) { mutableStateOf(value) }
+ var textFieldValueState by remember {
+ mutableStateOf(
+ TextFieldValue(
+ text = value,
+ selection = value.createSelection()
+ )
+ )
+ }
+
+ val textFieldValue = textFieldValueState.copy(
+ text = value,
+ selection = if (textFieldValueState.text.isBlank()) value.createSelection() else textFieldValueState.selection
+ )
+
+ SideEffect {
+ if (textFieldValue.selection != textFieldValueState.selection ||
+ textFieldValue.composition != textFieldValueState.composition
+ ) {
+ textFieldValueState = textFieldValue
+ }
+ }
+
+ var hasFocus by remember { mutableStateOf(false) }
+
+ // BasicTextField has a bug where it won't scroll down to keep the cursor in view.
+ val bringIntoViewRequester = BringIntoViewRequester()
+ val coroutineScope = rememberCoroutineScope()
+
CompositionLocalProvider(LocalTextSelectionColors provides TextSelectionColors(handleColor = LocalContentColor.current, LocalContentColor.current.copy(alpha = 0.4f))) {
BasicTextField(
- value = value,
+ value = textFieldValue,
modifier = modifier
+ .onFocusChanged { }
+ .bringIntoViewRequester(bringIntoViewRequester)
+ .onFocusChanged { focusState -> hasFocus = focusState.hasFocus }
.defaultMinSize(
minWidth = TextFieldDefaults.MinWidth,
minHeight = TextFieldDefaults.MinHeight
),
- onValueChange = onValueChange,
+ onValueChange = { newTextFieldValueState ->
+ textFieldValueState = newTextFieldValueState
+
+ val stringChangedSinceLastInvocation = lastTextValue != newTextFieldValueState.text
+ lastTextValue = newTextFieldValueState.text
+
+ if (stringChangedSinceLastInvocation) {
+ onValueChange(newTextFieldValueState.text)
+ }
+ },
enabled = enabled,
readOnly = readOnly,
textStyle = mergedTextStyle,
@@ -96,6 +153,15 @@ object TextFields {
singleLine = singleLine,
maxLines = maxLines,
minLines = minLines,
+ onTextLayout = { result ->
+ if (hasFocus && textFieldValue.selection.collapsed) {
+ val rect = result.getCursorRect(textFieldValue.selection.start)
+
+ coroutineScope.launch {
+ bringIntoViewRequester.bringIntoView(rect.translate(translateX = 0f, translateY = 72.dp.value))
+ }
+ }
+ },
decorationBox = @Composable { innerTextField ->
// places leading icon, text field with label and placeholder, trailing icon
TextFieldDefaults.DecorationBox(
@@ -121,4 +187,11 @@ object TextFields {
)
}
}
+
+ private fun String.createSelection(): TextRange {
+ return when {
+ isEmpty() -> TextRange.Zero
+ else -> TextRange(length, length)
+ }
+ }
}
diff --git a/core-ui/src/main/java/org/signal/core/ui/copied/androidx/compose/material3/AndroidMenu.android.kt b/core-ui/src/main/java/org/signal/core/ui/copied/androidx/compose/material3/AndroidMenu.android.kt
new file mode 100644
index 0000000000..e80759e5a8
--- /dev/null
+++ b/core-ui/src/main/java/org/signal/core/ui/copied/androidx/compose/material3/AndroidMenu.android.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.core.ui.copied.androidx.compose.material3
+
+import androidx.compose.animation.core.MutableTransitionState
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.TransformOrigin
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.DpOffset
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Popup
+import androidx.compose.ui.window.PopupProperties
+
+/**
+ * Lifted straight from Compose-Material3
+ *
+ * This eliminates the content padding on the dropdown menu.
+ */
+@Suppress("ModifierParameter")
+@Composable
+internal fun DropdownMenu(
+ expanded: Boolean,
+ onDismissRequest: () -> Unit,
+ modifier: Modifier = Modifier,
+ offset: DpOffset = DpOffset(0.dp, 0.dp),
+ properties: PopupProperties = PopupProperties(focusable = true),
+ content: @Composable ColumnScope.() -> Unit
+) {
+ val expandedStates = remember { MutableTransitionState(false) }
+ expandedStates.targetState = expanded
+
+ if (expandedStates.currentState || expandedStates.targetState) {
+ val transformOriginState = remember { mutableStateOf(TransformOrigin.Center) }
+ val density = LocalDensity.current
+ val popupPositionProvider = DropdownMenuPositionProvider(
+ offset,
+ density
+ ) { parentBounds, menuBounds ->
+ transformOriginState.value = calculateTransformOrigin(parentBounds, menuBounds)
+ }
+
+ Popup(
+ onDismissRequest = onDismissRequest,
+ popupPositionProvider = popupPositionProvider,
+ properties = properties
+ ) {
+ DropdownMenuContent(
+ expandedStates = expandedStates,
+ transformOriginState = transformOriginState,
+ modifier = modifier,
+ content = content
+ )
+ }
+ }
+}
diff --git a/core-ui/src/main/java/org/signal/core/ui/copied/androidx/compose/material3/Menu.kt b/core-ui/src/main/java/org/signal/core/ui/copied/androidx/compose/material3/Menu.kt
new file mode 100644
index 0000000000..025571fda1
--- /dev/null
+++ b/core-ui/src/main/java/org/signal/core/ui/copied/androidx/compose/material3/Menu.kt
@@ -0,0 +1,215 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.core.ui.copied.androidx.compose.material3
+
+import androidx.compose.animation.core.LinearOutSlowInEasing
+import androidx.compose.animation.core.MutableTransitionState
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.core.updateTransition
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.TransformOrigin
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.DpOffset
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.IntRect
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.PopupPositionProvider
+import kotlin.math.max
+import kotlin.math.min
+
+@Suppress("ModifierParameter", "TransitionPropertiesLabel")
+@Composable
+internal fun DropdownMenuContent(
+ expandedStates: MutableTransitionState,
+ transformOriginState: MutableState,
+ modifier: Modifier = Modifier,
+ content: @Composable ColumnScope.() -> Unit
+) {
+ // Menu open/close animation.
+ val transition = updateTransition(expandedStates, "DropDownMenu")
+
+ val scale by transition.animateFloat(
+ transitionSpec = {
+ if (false isTransitioningTo true) {
+ // Dismissed to expanded
+ tween(
+ durationMillis = InTransitionDuration,
+ easing = LinearOutSlowInEasing
+ )
+ } else {
+ // Expanded to dismissed.
+ tween(
+ durationMillis = 1,
+ delayMillis = OutTransitionDuration - 1
+ )
+ }
+ }
+ ) {
+ if (it) {
+ // Menu is expanded.
+ 1f
+ } else {
+ // Menu is dismissed.
+ 0.8f
+ }
+ }
+
+ val alpha by transition.animateFloat(
+ transitionSpec = {
+ if (false isTransitioningTo true) {
+ // Dismissed to expanded
+ tween(durationMillis = 30)
+ } else {
+ // Expanded to dismissed.
+ tween(durationMillis = OutTransitionDuration)
+ }
+ }
+ ) {
+ if (it) {
+ // Menu is expanded.
+ 1f
+ } else {
+ // Menu is dismissed.
+ 0f
+ }
+ }
+ Surface(
+ modifier = Modifier.graphicsLayer {
+ scaleX = scale
+ scaleY = scale
+ this.alpha = alpha
+ transformOrigin = transformOriginState.value
+ },
+ shape = MaterialTheme.shapes.extraSmall,
+ color = MaterialTheme.colorScheme.surface,
+ tonalElevation = 3.dp,
+ shadowElevation = 3.dp
+ ) {
+ Column(
+ modifier = modifier
+ .width(IntrinsicSize.Max)
+ .verticalScroll(rememberScrollState()),
+ content = content
+ )
+ }
+}
+
+internal fun calculateTransformOrigin(
+ parentBounds: IntRect,
+ menuBounds: IntRect
+): TransformOrigin {
+ val pivotX = when {
+ menuBounds.left >= parentBounds.right -> 0f
+ menuBounds.right <= parentBounds.left -> 1f
+ menuBounds.width == 0 -> 0f
+ else -> {
+ val intersectionCenter =
+ (
+ max(parentBounds.left, menuBounds.left) +
+ min(parentBounds.right, menuBounds.right)
+ ) / 2
+ (intersectionCenter - menuBounds.left).toFloat() / menuBounds.width
+ }
+ }
+ val pivotY = when {
+ menuBounds.top >= parentBounds.bottom -> 0f
+ menuBounds.bottom <= parentBounds.top -> 1f
+ menuBounds.height == 0 -> 0f
+ else -> {
+ val intersectionCenter =
+ (
+ max(parentBounds.top, menuBounds.top) +
+ min(parentBounds.bottom, menuBounds.bottom)
+ ) / 2
+ (intersectionCenter - menuBounds.top).toFloat() / menuBounds.height
+ }
+ }
+ return TransformOrigin(pivotX, pivotY)
+}
+
+@Immutable
+internal data class DropdownMenuPositionProvider(
+ val contentOffset: DpOffset,
+ val density: Density,
+ val onPositionCalculated: (IntRect, IntRect) -> Unit = { _, _ -> }
+) : PopupPositionProvider {
+ override fun calculatePosition(
+ anchorBounds: IntRect,
+ windowSize: IntSize,
+ layoutDirection: LayoutDirection,
+ popupContentSize: IntSize
+ ): IntOffset {
+ // The min margin above and below the menu, relative to the screen.
+ val verticalMargin = with(density) { MenuVerticalMargin.roundToPx() }
+ // The content offset specified using the dropdown offset parameter.
+ val contentOffsetX = with(density) { contentOffset.x.roundToPx() }
+ val contentOffsetY = with(density) { contentOffset.y.roundToPx() }
+
+ // Compute horizontal position.
+ val toRight = anchorBounds.left + contentOffsetX
+ val toLeft = anchorBounds.right - contentOffsetX - popupContentSize.width
+ val toDisplayRight = windowSize.width - popupContentSize.width
+ val toDisplayLeft = 0
+ val x = if (layoutDirection == LayoutDirection.Ltr) {
+ sequenceOf(
+ toRight,
+ toLeft,
+ // If the anchor gets outside of the window on the left, we want to position
+ // toDisplayLeft for proximity to the anchor. Otherwise, toDisplayRight.
+ if (anchorBounds.left >= 0) toDisplayRight else toDisplayLeft
+ )
+ } else {
+ sequenceOf(
+ toLeft,
+ toRight,
+ // If the anchor gets outside of the window on the right, we want to position
+ // toDisplayRight for proximity to the anchor. Otherwise, toDisplayLeft.
+ if (anchorBounds.right <= windowSize.width) toDisplayLeft else toDisplayRight
+ )
+ }.firstOrNull {
+ it >= 0 && it + popupContentSize.width <= windowSize.width
+ } ?: toLeft
+
+ // Compute vertical position.
+ val toBottom = maxOf(anchorBounds.bottom + contentOffsetY, verticalMargin)
+ val toTop = anchorBounds.top - contentOffsetY - popupContentSize.height
+ val toCenter = anchorBounds.top - popupContentSize.height / 2
+ val toDisplayBottom = windowSize.height - popupContentSize.height - verticalMargin
+ val y = sequenceOf(toBottom, toTop, toCenter, toDisplayBottom).firstOrNull {
+ it >= verticalMargin &&
+ it + popupContentSize.height <= windowSize.height - verticalMargin
+ } ?: toTop
+
+ onPositionCalculated(
+ anchorBounds,
+ IntRect(x, y, x + popupContentSize.width, y + popupContentSize.height)
+ )
+ return IntOffset(x, y)
+ }
+}
+
+// Size defaults.
+internal val MenuVerticalMargin = 48.dp
+
+// Menu open/close animation.
+internal const val InTransitionDuration = 120
+internal const val OutTransitionDuration = 75