Fix NewMessage with ONS

This commit is contained in:
Andrew 2024-06-18 11:28:36 +09:30
parent bf3835d6a6
commit 71e7dfb131
5 changed files with 165 additions and 197 deletions

View File

@ -0,0 +1,123 @@
package org.thoughtcrime.securesms.conversation.newmessage
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import network.loki.messenger.R
import org.thoughtcrime.securesms.ui.LoadingArcOr
import org.thoughtcrime.securesms.ui.LocalDimensions
import org.thoughtcrime.securesms.ui.PreviewTheme
import org.thoughtcrime.securesms.ui.SessionColorsParameterProvider
import org.thoughtcrime.securesms.ui.color.Colors
import org.thoughtcrime.securesms.ui.color.LocalColors
import org.thoughtcrime.securesms.ui.components.AppBar
import org.thoughtcrime.securesms.ui.components.BorderlessButtonWithIcon
import org.thoughtcrime.securesms.ui.components.MaybeScanQrCode
import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField
import org.thoughtcrime.securesms.ui.components.SessionTabRow
import org.thoughtcrime.securesms.ui.components.SlimOutlineButton
import org.thoughtcrime.securesms.ui.contentDescription
private val TITLES = listOf(R.string.enter_account_id, R.string.qrScan)
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun NewMessage(
state: State,
errors: Flow<String> = emptyFlow(),
callbacks: Callbacks = object: Callbacks {},
onClose: () -> Unit = {},
onBack: () -> Unit = {},
onHelp: () -> Unit = {},
) {
val pagerState = rememberPagerState { TITLES.size }
Column(modifier = Modifier.background(LocalColors.current.backgroundSecondary)) {
AppBar(stringResource(R.string.messageNew), onClose = onClose, onBack = onBack)
SessionTabRow(pagerState, TITLES)
HorizontalPager(pagerState) {
when (TITLES[it]) {
R.string.enter_account_id -> EnterAccountId(state, callbacks, onHelp)
R.string.qrScan -> MaybeScanQrCode(errors, onScan = callbacks::onScanQrCode)
}
}
}
}
@Composable
private fun EnterAccountId(
state: State,
callbacks: Callbacks,
onHelp: () -> Unit = {}
) {
Column(
modifier = Modifier
.padding(horizontal = LocalDimensions.current.marginExtraExtraSmall, vertical = LocalDimensions.current.marginExtraSmall)
.fillMaxHeight(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.marginExtraSmall)
) {
SessionOutlinedTextField(
text = state.newMessageIdOrOns,
modifier = Modifier
.padding(horizontal = LocalDimensions.current.marginSmall)
.contentDescription("Session id input box"),
placeholder = stringResource(R.string.accountIdOrOnsEnter),
onChange = callbacks::onChange,
onContinue = callbacks::onContinue,
error = state.error?.string(),
)
BorderlessButtonWithIcon(
text = stringResource(R.string.messageNewDescription),
iconRes = R.drawable.ic_circle_question_mark,
contentColor = LocalColors.current.textSecondary,
modifier = Modifier
.animateContentSize()
.contentDescription(R.string.AccessibilityId_help_desk_link)
.padding(horizontal = LocalDimensions.current.marginMedium)
.fillMaxWidth(),
) { onHelp() }
SlimOutlineButton(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(horizontal = LocalDimensions.current.marginLarge)
.fillMaxWidth()
.contentDescription(R.string.next),
color = LocalColors.current.primary,
enabled = state.isNextButtonEnabled,
onClick = { callbacks.onContinue() }
) {
LoadingArcOr(state.loading) {
Text(stringResource(R.string.next))
}
}
}
}
@Preview
@Composable
private fun PreviewNewMessage(
@PreviewParameter(SessionColorsParameterProvider::class) colors: Colors
) {
PreviewTheme(colors) {
NewMessage(State())
}
}

View File

@ -5,66 +5,25 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.expandIn
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.shrinkOut
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch
import network.loki.messenger.R
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.conversation.newmessage.Callbacks
import org.thoughtcrime.securesms.conversation.newmessage.Event
import org.thoughtcrime.securesms.conversation.newmessage.NewMessage
import org.thoughtcrime.securesms.conversation.newmessage.NewMessageViewModel
import org.thoughtcrime.securesms.conversation.newmessage.State
import org.thoughtcrime.securesms.conversation.start.NewConversationDelegate
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.showOpenUrlDialog
import org.thoughtcrime.securesms.ui.color.Colors
import org.thoughtcrime.securesms.ui.LoadingArcOr
import org.thoughtcrime.securesms.ui.color.LocalColors
import org.thoughtcrime.securesms.ui.LocalDimensions
import org.thoughtcrime.securesms.ui.PreviewTheme
import org.thoughtcrime.securesms.ui.SessionColorsParameterProvider
import org.thoughtcrime.securesms.ui.components.AppBar
import org.thoughtcrime.securesms.ui.components.BorderlessButtonWithIcon
import org.thoughtcrime.securesms.ui.components.MaybeScanQrCode
import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField
import org.thoughtcrime.securesms.ui.components.SessionTabRow
import org.thoughtcrime.securesms.ui.components.SlimOutlineButton
import org.thoughtcrime.securesms.ui.contentDescription
import org.thoughtcrime.securesms.ui.createThemedComposeView
class NewMessageFragment : Fragment() {
val viewModel: NewMessageViewModel by viewModels()
private val viewModel: NewMessageViewModel by viewModels()
lateinit var delegate: NewConversationDelegate
@ -72,8 +31,8 @@ class NewMessageFragment : Fragment() {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
viewModel.event.filterIsInstance<Event.Success>().collect {
createPrivateChat(it.key)
viewModel.success.collect {
createPrivateChat(it.publicKey)
}
}
}
@ -103,91 +62,3 @@ class NewMessageFragment : Fragment() {
delegate.onDialogClosePressed()
}
}
@Preview
@Composable
private fun PreviewNewMessage(
@PreviewParameter(SessionColorsParameterProvider::class) colors: Colors
) {
PreviewTheme(colors) {
NewMessage(State())
}
}
private val TITLES = listOf(R.string.enter_account_id, R.string.qrScan)
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun NewMessage(
state: State,
errors: Flow<String> = emptyFlow(),
callbacks: Callbacks = object: Callbacks {},
onClose: () -> Unit = {},
onBack: () -> Unit = {},
onHelp: () -> Unit = {},
) {
val pagerState = rememberPagerState { TITLES.size }
Column(modifier = Modifier.background(LocalColors.current.backgroundSecondary)) {
AppBar(stringResource(R.string.messageNew), onClose = onClose, onBack = onBack)
SessionTabRow(pagerState, TITLES)
HorizontalPager(pagerState) {
when (TITLES[it]) {
R.string.enter_account_id -> EnterAccountId(state, callbacks, onHelp)
R.string.qrScan -> MaybeScanQrCode(errors, onScan = callbacks::onScanQrCode)
}
}
}
}
@Composable
fun EnterAccountId(
state: State,
callbacks: Callbacks,
onHelp: () -> Unit = {}
) {
Column(
modifier = Modifier
.padding(horizontal = LocalDimensions.current.marginExtraExtraSmall, vertical = LocalDimensions.current.marginExtraSmall)
.fillMaxHeight(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.marginExtraSmall)
) {
SessionOutlinedTextField(
text = state.newMessageIdOrOns,
modifier = Modifier
.padding(horizontal = LocalDimensions.current.marginSmall)
.contentDescription("Session id input box"),
placeholder = stringResource(R.string.accountIdOrOnsEnter),
onChange = callbacks::onChange,
onContinue = callbacks::onContinue,
error = state.error?.string(),
)
BorderlessButtonWithIcon(
text = stringResource(R.string.messageNewDescription),
iconRes = R.drawable.ic_circle_question_mark,
contentColor = LocalColors.current.textSecondary,
modifier = Modifier
.animateContentSize()
.contentDescription(R.string.AccessibilityId_help_desk_link)
.padding(horizontal = LocalDimensions.current.marginMedium)
.fillMaxWidth(),
) { onHelp() }
SlimOutlineButton(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(horizontal = LocalDimensions.current.marginLarge)
.fillMaxWidth()
.contentDescription(R.string.next),
color = LocalColors.current.primary,
enabled = state.isNextButtonEnabled,
onClick = { callbacks.onContinue() }
) {
LoadingArcOr(state.loading) {
Text(stringResource(R.string.next))
}
}
}
}

View File

@ -5,37 +5,34 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.timeout
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import network.loki.messenger.R
import org.session.libsession.snode.SnodeAPI
import org.session.libsignal.utilities.PublicKeyValidation
import org.session.libsignal.utilities.asFlow
import org.thoughtcrime.securesms.ui.GetString
import javax.inject.Inject
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration.Companion.seconds
@HiltViewModel
class NewMessageViewModel @Inject constructor(
internal class NewMessageViewModel @Inject constructor(
private val application: Application
): AndroidViewModel(application), Callbacks {
private val _state = MutableStateFlow(State())
val state = _state.asStateFlow()
private val _event = Channel<Event>()
val event: Flow<Event> get() = _event.receiveAsFlow()
private val _success = Channel<Success>()
val success: Flow<Success> get() = _success.receiveAsFlow()
private val _qrErrors = Channel<String>()
val qrErrors: Flow<String> = _qrErrors.receiveAsFlow()
@ -50,7 +47,13 @@ class NewMessageViewModel @Inject constructor(
}
override fun onContinue() {
createPrivateChatIfPossible(state.value.newMessageIdOrOns)
val idOrONS = state.value.newMessageIdOrOns
if (PublicKeyValidation.isValid(idOrONS, isPrefixRequired = false)) {
onUnvalidatedPublicKey(idOrONS)
} else {
resolveONS(idOrONS)
}
}
override fun onScanQrCode(value: String) {
@ -61,38 +64,23 @@ class NewMessageViewModel @Inject constructor(
}
}
@OptIn(FlowPreview::class)
private fun createPrivateChatIfPossible(onsNameOrPublicKey: String) {
private fun resolveONS(ons: String) {
if (loadOnsJob?.isActive == true) return
if (PublicKeyValidation.isValid(onsNameOrPublicKey, isPrefixRequired = false)) {
if (PublicKeyValidation.hasValidPrefix(onsNameOrPublicKey)) {
onPublicKey(onsNameOrPublicKey)
} else {
_state.update { it.copy(error = GetString(R.string.accountIdErrorInvalid), loading = false) }
}
} else {
// This could be an ONS name
_state.update { it.copy(error = null, loading = true) }
// This could be an ONS name
_state.update { it.copy(error = null, loading = true) }
loadOnsJob = viewModelScope.launch(Dispatchers.IO) {
try {
// TODO move timeout to SnodeAPI#getSessionID
SnodeAPI.getSessionID(onsNameOrPublicKey).asFlow()
.timeout(30.seconds)
.collectLatest {
_state.update { it.copy(loading = false) }
onPublicKey(onsNameOrPublicKey)
}
} catch (e: TimeoutCancellationException) {
onError(e)
} catch (e: CancellationException) {
// Ignore JobCancellationException, which is called when we cancel the job and
// is handled where the job is canceled.
// Can't reference JobCancellationException directly, it is internal.
} catch (e: Exception) {
onError(e)
}
loadOnsJob = viewModelScope.launch(Dispatchers.IO) {
try {
val publicKey = withTimeout(30.seconds) { SnodeAPI.getSessionID(ons).get() }
onPublicKey(publicKey)
} catch (e: TimeoutCancellationException) {
onError(e)
} catch (e: CancellationException) {
// Attempting to just ignore internal JobCancellationException, which is called
// when we cancel the job, state update is handled there.
} catch (e: Exception) {
onError(e)
}
}
}
@ -101,8 +89,17 @@ class NewMessageViewModel @Inject constructor(
_state.update { it.copy(loading = false, error = GetString(e) { it.toMessage() }) }
}
private fun onPublicKey(onsNameOrPublicKey: String) {
viewModelScope.launch { _event.send(Event.Success(onsNameOrPublicKey)) }
private fun onPublicKey(publicKey: String) {
_state.update { it.copy(loading = false) }
viewModelScope.launch { _success.send(Success(publicKey)) }
}
private fun onUnvalidatedPublicKey(publicKey: String) {
if (PublicKeyValidation.hasValidPrefix(publicKey)) {
onPublicKey(publicKey)
} else {
_state.update { it.copy(error = GetString(R.string.accountIdErrorInvalid), loading = false) }
}
}
private fun Exception.toMessage() = when (this) {
@ -111,7 +108,7 @@ class NewMessageViewModel @Inject constructor(
}
}
data class State(
internal data class State(
val newMessageIdOrOns: String = "",
val error: GetString? = null,
val loading: Boolean = false
@ -119,6 +116,4 @@ data class State(
val isNextButtonEnabled: Boolean get() = newMessageIdOrOns.isNotBlank()
}
sealed interface Event {
data class Success(val key: String): Event
}
internal data class Success(val publicKey: String)

View File

@ -10,7 +10,6 @@ import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import network.loki.messenger.R
import network.loki.messenger.databinding.ItemSelectableBinding
import network.loki.messenger.libsession_util.util.ExpiryMode
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.ui.GetString
import java.util.Objects
@ -68,7 +67,6 @@ class RadioOptionAdapter<T>(
}
}
}
}
data class RadioOption<out T>(

View File

@ -1,9 +1,6 @@
@file:JvmName("PromiseUtilities")
package org.session.libsignal.utilities
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.isActive
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.deferred
import nl.komponents.kovenant.functional.map
@ -70,19 +67,3 @@ infix fun <V, E: Exception> Promise<V, E>.sideEffect(
callback(it)
it
}
/**
* Observe a [Promise] as a flow
*
* Warning: Promise will not be canceled on unsubscribe.
*/
fun <V, E: Exception> Promise<V, E>.asFlow() = callbackFlow {
success {
if (isActive) trySend(it)
close()
} fail {
close(it)
}
awaitClose()
}