Implement the majority of the new nicknames and notes feature.

This commit is contained in:
Alex Hart
2024-03-22 16:03:04 -03:00
committed by Nicholas Tinsley
parent 7a24554b68
commit 303929090b
20 changed files with 1504 additions and 23 deletions

View File

@@ -14,6 +14,7 @@ import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import androidx.core.view.doOnPreDraw
@@ -77,6 +78,7 @@ import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupsLearnMoreB
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository
import org.thoughtcrime.securesms.nicknames.NicknameActivity
import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientExporter
@@ -92,6 +94,7 @@ import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.ContextUtil
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.ExpirationUtil
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.Material3OnScrollHelper
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
@@ -149,6 +152,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
private lateinit var toolbarTitle: TextView
private lateinit var toolbarBackground: View
private lateinit var addToGroupStoryDelegate: AddToGroupStoryDelegate
private lateinit var nicknameLauncher: ActivityResultLauncher<NicknameActivity.Args>
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),

View File

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

View File

@@ -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<Args, Unit>() {
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
)
}
}
}

View File

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

View File

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

View File

@@ -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<NicknameActivity.Args>
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)
}
}
}

View File

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

View File

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

View File

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

View File

@@ -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<NicknameActivity.Args> 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();

View File

@@ -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<String, Object> getMemoryValues() {
return new TreeMap<>(REMOTE_VALUES);