From 303929090b901acf2e831a0e80671e3b6fe0bf0d Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Fri, 22 Mar 2024 16:03:04 -0300 Subject: [PATCH] Implement the majority of the new nicknames and notes feature. --- app/src/main/AndroidManifest.xml | 5 + .../ConversationSettingsFragment.kt | 23 + .../securesms/database/RecipientTable.kt | 14 + .../securesms/nicknames/NicknameActivity.kt | 500 ++++++++++++++++++ .../securesms/nicknames/NicknameState.kt | 33 ++ .../securesms/nicknames/NicknameViewModel.kt | 127 +++++ .../securesms/nicknames/ViewNoteSheet.kt | 169 ++++++ .../nicknames/ViewNoteSheetViewModel.kt | 32 ++ .../securesms/recipients/Recipient.java | 4 + .../recipients/ui/about/AboutSheet.kt | 66 ++- .../RecipientBottomSheetDialogFragment.java | 19 + .../securesms/util/FeatureFlags.java | 9 +- .../res/drawable/symbol_x_circle_fill_24.xml | 9 + .../res/layout/recipient_bottom_sheet.xml | 17 + app/src/main/res/values/strings.xml | 28 + .../java/org/signal/core/ui/DropdownMenus.kt | 94 ++++ .../main/java/org/signal/core/ui/Scaffolds.kt | 20 +- .../java/org/signal/core/ui/TextFields.kt | 81 ++- .../compose/material3/AndroidMenu.android.kt | 62 +++ .../copied/androidx/compose/material3/Menu.kt | 215 ++++++++ 20 files changed, 1504 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/nicknames/NicknameActivity.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/nicknames/NicknameState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/nicknames/NicknameViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/nicknames/ViewNoteSheet.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/nicknames/ViewNoteSheetViewModel.kt create mode 100644 app/src/main/res/drawable/symbol_x_circle_fill_24.xml create mode 100644 core-ui/src/main/java/org/signal/core/ui/DropdownMenus.kt create mode 100644 core-ui/src/main/java/org/signal/core/ui/copied/androidx/compose/material3/AndroidMenu.android.kt create mode 100644 core-ui/src/main/java/org/signal/core/ui/copied/androidx/compose/material3/Menu.kt 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