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.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup 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.collectAsState
import androidx.compose.runtime.getValue 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.Fragment
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import network.loki.messenger.R
import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.conversation.newmessage.Callbacks import org.thoughtcrime.securesms.conversation.newmessage.NewMessage
import org.thoughtcrime.securesms.conversation.newmessage.Event
import org.thoughtcrime.securesms.conversation.newmessage.NewMessageViewModel import org.thoughtcrime.securesms.conversation.newmessage.NewMessageViewModel
import org.thoughtcrime.securesms.conversation.newmessage.State import org.thoughtcrime.securesms.conversation.newmessage.State
import org.thoughtcrime.securesms.conversation.start.NewConversationDelegate import org.thoughtcrime.securesms.conversation.start.NewConversationDelegate
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.showOpenUrlDialog 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 import org.thoughtcrime.securesms.ui.createThemedComposeView
class NewMessageFragment : Fragment() { class NewMessageFragment : Fragment() {
private val viewModel: NewMessageViewModel by viewModels()
val viewModel: NewMessageViewModel by viewModels()
lateinit var delegate: NewConversationDelegate lateinit var delegate: NewConversationDelegate
@ -72,8 +31,8 @@ class NewMessageFragment : Fragment() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
lifecycleScope.launch { lifecycleScope.launch {
viewModel.event.filterIsInstance<Event.Success>().collect { viewModel.success.collect {
createPrivateChat(it.key) createPrivateChat(it.publicKey)
} }
} }
} }
@ -103,91 +62,3 @@ class NewMessageFragment : Fragment() {
delegate.onDialogClosePressed() 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 androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.timeout
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeAPI
import org.session.libsignal.utilities.PublicKeyValidation import org.session.libsignal.utilities.PublicKeyValidation
import org.session.libsignal.utilities.asFlow
import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.GetString
import javax.inject.Inject import javax.inject.Inject
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@HiltViewModel @HiltViewModel
class NewMessageViewModel @Inject constructor( internal class NewMessageViewModel @Inject constructor(
private val application: Application private val application: Application
): AndroidViewModel(application), Callbacks { ): AndroidViewModel(application), Callbacks {
private val _state = MutableStateFlow(State()) private val _state = MutableStateFlow(State())
val state = _state.asStateFlow() val state = _state.asStateFlow()
private val _event = Channel<Event>() private val _success = Channel<Success>()
val event: Flow<Event> get() = _event.receiveAsFlow() val success: Flow<Success> get() = _success.receiveAsFlow()
private val _qrErrors = Channel<String>() private val _qrErrors = Channel<String>()
val qrErrors: Flow<String> = _qrErrors.receiveAsFlow() val qrErrors: Flow<String> = _qrErrors.receiveAsFlow()
@ -50,7 +47,13 @@ class NewMessageViewModel @Inject constructor(
} }
override fun onContinue() { 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) { override fun onScanQrCode(value: String) {
@ -61,38 +64,23 @@ class NewMessageViewModel @Inject constructor(
} }
} }
@OptIn(FlowPreview::class) private fun resolveONS(ons: String) {
private fun createPrivateChatIfPossible(onsNameOrPublicKey: String) {
if (loadOnsJob?.isActive == true) return if (loadOnsJob?.isActive == true) return
if (PublicKeyValidation.isValid(onsNameOrPublicKey, isPrefixRequired = false)) { // This could be an ONS name
if (PublicKeyValidation.hasValidPrefix(onsNameOrPublicKey)) { _state.update { it.copy(error = null, loading = true) }
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) }
loadOnsJob = viewModelScope.launch(Dispatchers.IO) { loadOnsJob = viewModelScope.launch(Dispatchers.IO) {
try { try {
// TODO move timeout to SnodeAPI#getSessionID val publicKey = withTimeout(30.seconds) { SnodeAPI.getSessionID(ons).get() }
SnodeAPI.getSessionID(onsNameOrPublicKey).asFlow() onPublicKey(publicKey)
.timeout(30.seconds) } catch (e: TimeoutCancellationException) {
.collectLatest { onError(e)
_state.update { it.copy(loading = false) } } catch (e: CancellationException) {
onPublicKey(onsNameOrPublicKey) // Attempting to just ignore internal JobCancellationException, which is called
} // when we cancel the job, state update is handled there.
} catch (e: TimeoutCancellationException) { } catch (e: Exception) {
onError(e) 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)
}
} }
} }
} }
@ -101,8 +89,17 @@ class NewMessageViewModel @Inject constructor(
_state.update { it.copy(loading = false, error = GetString(e) { it.toMessage() }) } _state.update { it.copy(loading = false, error = GetString(e) { it.toMessage() }) }
} }
private fun onPublicKey(onsNameOrPublicKey: String) { private fun onPublicKey(publicKey: String) {
viewModelScope.launch { _event.send(Event.Success(onsNameOrPublicKey)) } _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) { 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 newMessageIdOrOns: String = "",
val error: GetString? = null, val error: GetString? = null,
val loading: Boolean = false val loading: Boolean = false
@ -119,6 +116,4 @@ data class State(
val isNextButtonEnabled: Boolean get() = newMessageIdOrOns.isNotBlank() val isNextButtonEnabled: Boolean get() = newMessageIdOrOns.isNotBlank()
} }
sealed interface Event { internal data class Success(val publicKey: String)
data class Success(val key: String): Event
}

View File

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

View File

@ -1,9 +1,6 @@
@file:JvmName("PromiseUtilities") @file:JvmName("PromiseUtilities")
package org.session.libsignal.utilities 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.Promise
import nl.komponents.kovenant.deferred import nl.komponents.kovenant.deferred
import nl.komponents.kovenant.functional.map import nl.komponents.kovenant.functional.map
@ -70,19 +67,3 @@ infix fun <V, E: Exception> Promise<V, E>.sideEffect(
callback(it) callback(it)
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()
}