diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/newmessage/NewMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/newmessage/NewMessage.kt new file mode 100644 index 0000000000..d262d818fd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/newmessage/NewMessage.kt @@ -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 = 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()) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/newmessage/NewMessageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/newmessage/NewMessageFragment.kt index 23f3c46d99..3dd72f3528 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/newmessage/NewMessageFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/newmessage/NewMessageFragment.kt @@ -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().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 = 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)) - } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/newmessage/NewMessageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/newmessage/NewMessageViewModel.kt index a0c6ba50f8..8551722c15 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/newmessage/NewMessageViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/newmessage/NewMessageViewModel.kt @@ -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() - val event: Flow get() = _event.receiveAsFlow() + private val _success = Channel() + val success: Flow get() = _success.receiveAsFlow() private val _qrErrors = Channel() val qrErrors: Flow = _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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/RadioOptionAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/RadioOptionAdapter.kt index f80acee64e..60b5fb8b2c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/RadioOptionAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/RadioOptionAdapter.kt @@ -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( } } } - } data class RadioOption( diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/PromiseUtilities.kt b/libsignal/src/main/java/org/session/libsignal/utilities/PromiseUtilities.kt index b2e187f134..fdf8f107b9 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/PromiseUtilities.kt +++ b/libsignal/src/main/java/org/session/libsignal/utilities/PromiseUtilities.kt @@ -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 Promise.sideEffect( callback(it) it } - -/** - * Observe a [Promise] as a flow - * - * Warning: Promise will not be canceled on unsubscribe. - */ -fun Promise.asFlow() = callbackFlow { - success { - if (isActive) trySend(it) - close() - } fail { - close(it) - } - - awaitClose() -}