diff --git a/src/main/kotlin/org/briarproject/briar/desktop/contact/add/remote/AddContactDialog.kt b/src/main/kotlin/org/briarproject/briar/desktop/contact/add/remote/AddContactDialog.kt index 22cb9385bb4fd26efa3f9e293b467fabbd9d9ecd..ff5f2e959b4fb614a05e801f14c2c8eab8b63580 100644 --- a/src/main/kotlin/org/briarproject/briar/desktop/contact/add/remote/AddContactDialog.kt +++ b/src/main/kotlin/org/briarproject/briar/desktop/contact/add/remote/AddContactDialog.kt @@ -24,65 +24,111 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.material.AlertDialog import androidx.compose.material.Button import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.material.TextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Warning import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.briarproject.briar.desktop.contact.add.remote.AddContactViewModel.AddContactError +import org.briarproject.briar.desktop.contact.add.remote.AddContactViewModel.AliasInvalidError +import org.briarproject.briar.desktop.contact.add.remote.AddContactViewModel.ErrorContactAlreadyExists +import org.briarproject.briar.desktop.contact.add.remote.AddContactViewModel.ErrorPendingAlreadyExists +import org.briarproject.briar.desktop.contact.add.remote.AddContactViewModel.LinkInvalidError +import org.briarproject.briar.desktop.contact.add.remote.AddContactViewModel.OwnLinkError +import org.briarproject.briar.desktop.contact.add.remote.AddContactViewModel.PublicKeyInvalidError +import org.briarproject.briar.desktop.contact.add.remote.AddContactViewModel.RemoteInvalidError +import org.briarproject.briar.desktop.dialogs.DialogType.ERROR +import org.briarproject.briar.desktop.dialogs.DialogType.WARNING +import org.briarproject.briar.desktop.theme.Orange500 +import org.briarproject.briar.desktop.theme.Red500 +import org.briarproject.briar.desktop.ui.Constants.DIALOG_WIDTH +import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n +import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18nF +import org.briarproject.briar.desktop.utils.PreviewUtils import org.briarproject.briar.desktop.utils.PreviewUtils.preview import org.briarproject.briar.desktop.viewmodel.viewModel +const val link = "briar://ady23gvb2r76afe5zhxh5kvnh4b22zrcnxibn63tfknrdcwrw7zrs" + fun main() = preview( "visible" to true, "remote link" to "", - "local link" to "briar://ady23gvb2r76afe5zhxh5kvnh4b22zrcnxibn63tfknrdcwrw7zrs", + "local link" to link, "alias" to "Alice", -) { - if (getBooleanParameter("visible")) { - AddContactDialog( - onClose = { setBooleanParameter("visible", false) }, - remoteHandshakeLink = getStringParameter("remote link"), - setRemoteHandshakeLink = { link -> setStringParameter("remote link", link) }, - alias = getStringParameter("alias"), - setAddContactAlias = { alias -> setStringParameter("alias", alias) }, - handshakeLink = getStringParameter("local link"), - onSubmitAddContactDialog = {} + "error visible" to false, + "error type" to PreviewUtils.Values( + 0, + listOf( + OwnLinkError(link), + RemoteInvalidError(link), + AliasInvalidError(link, ""), + LinkInvalidError(link), + PublicKeyInvalidError(link), + ErrorContactAlreadyExists(link, "David", "chuck"), + ErrorPendingAlreadyExists(link, "Frank", "chuck"), ) - } + ) { error -> error.javaClass.simpleName }, +) { + val localLink = getStringParameter("local link") + AddContactDialog( + onClose = { setBooleanParameter("visible", false) }, + visible = getBooleanParameter("visible"), + remoteHandshakeLink = getStringParameter("remote link"), + setRemoteHandshakeLink = { link -> setStringParameter("remote link", link) }, + alias = getStringParameter("alias"), + setAddContactAlias = { alias -> setStringParameter("alias", alias) }, + handshakeLink = localLink, + onSubmitAddContactDialog = { setBooleanParameter("error visible", true) }, + error = if (getBooleanParameter("error visible")) getGenericParameter("error type") else null, + onErrorDialogDismissed = { setBooleanParameter("error visible", false) }, + ) } @Composable fun AddContactDialog( - onClose: () -> Unit, viewModel: AddContactViewModel = viewModel(), ) = AddContactDialog( - onClose = onClose, + viewModel::dismissDialog, + viewModel.visible.value, viewModel.remoteHandshakeLink.value, viewModel::setRemoteHandshakeLink, viewModel.alias.value, viewModel::setAddContactAlias, viewModel.handshakeLink.value, viewModel::onSubmitAddContactDialog, + viewModel.error.value, + viewModel::clearError, ) @OptIn(ExperimentalMaterialApi::class) @Composable fun AddContactDialog( onClose: () -> Unit, + visible: Boolean, remoteHandshakeLink: String, setRemoteHandshakeLink: (String) -> Unit, alias: String, setAddContactAlias: (String) -> Unit, handshakeLink: String, onSubmitAddContactDialog: () -> Unit, + error: AddContactError?, + onErrorDialogDismissed: () -> Unit, ) { + if (!visible) { + return + } AlertDialog( onDismissRequest = onClose, text = { @@ -130,7 +176,11 @@ fun AddContactDialog( } }, confirmButton = { - Button(onClick = { onSubmitAddContactDialog(); onClose() }) { + Button( + onClick = { + onSubmitAddContactDialog() + } + ) { Text("Add") } }, @@ -142,4 +192,55 @@ fun AddContactDialog( } }, ) + if (error != null) { + val (type, title, message) = errorMessage(error) + val (icon, color) = when (type) { + WARNING -> Icons.Filled.Warning to Orange500 + ERROR -> Icons.Filled.Error to Red500 + } + AlertDialog( + onDismissRequest = onErrorDialogDismissed, + confirmButton = { + TextButton(onErrorDialogDismissed) { + Text(i18n("ok")) + } + }, + modifier = Modifier.widthIn(min = DIALOG_WIDTH), + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = icon, + contentDescription = title, + tint = color + ) + Text(title) + } + }, + text = { Text(message) } + ) + } +} + +fun errorMessage(error: AddContactError) = when (error) { + is OwnLinkError -> Triple(ERROR, i18n("error"), i18n("introduction.error.own_link")) + is RemoteInvalidError -> Triple(ERROR, i18n("error"), i18n("introduction.error.remote_invalid")) + is AliasInvalidError -> Triple(ERROR, i18n("error"), i18n("introduction.error.alias_invalid")) + is LinkInvalidError -> Triple(ERROR, i18n("error"), i18nF("introduction.error.link_invalid", error.link)) + is PublicKeyInvalidError -> Triple( + ERROR, i18n("error"), + i18nF("introduction.error.public_key_invalid", error.link) + ) + is ErrorContactAlreadyExists -> { + val intro = i18nF("introduction.error.contact_already_exists", error.existingName) + var explanation = i18nF("introduction.error.duplicate_contact_explainer", error.existingName, error.alias) + Triple(WARNING, i18n("introduction.error.adding_failed"), (intro + "\n\n" + explanation)) + } + is ErrorPendingAlreadyExists -> { + val intro = i18nF("introduction.error.pending_contact_already_exists", error.existingAlias) + var explanation = i18nF("introduction.error.duplicate_contact_explainer", error.existingAlias, error.alias) + Triple(WARNING, i18n("introduction.error.adding_failed"), (intro + "\n\n" + explanation)) + } } diff --git a/src/main/kotlin/org/briarproject/briar/desktop/contact/add/remote/AddContactViewModel.kt b/src/main/kotlin/org/briarproject/briar/desktop/contact/add/remote/AddContactViewModel.kt index c1b6773c63d129e9fff1827e1816116e66f6433b..235d1cec4261fbc4d7d07fd0b5fa8b9e97da2a47 100644 --- a/src/main/kotlin/org/briarproject/briar/desktop/contact/add/remote/AddContactViewModel.kt +++ b/src/main/kotlin/org/briarproject/briar/desktop/contact/add/remote/AddContactViewModel.kt @@ -48,18 +48,44 @@ constructor( private val LOG = KotlinLogging.logger {} } + sealed interface AddContactError + + data class OwnLinkError(val link: String) : AddContactError + data class RemoteInvalidError(val link: String) : AddContactError + data class AliasInvalidError(val link: String, val alias: String) : AddContactError + data class LinkInvalidError(val link: String) : AddContactError + data class PublicKeyInvalidError(val link: String) : AddContactError + + data class ErrorContactAlreadyExists(val link: String, val existingName: String, val alias: String) : + AddContactError + + data class ErrorPendingAlreadyExists(val link: String, val existingAlias: String, val alias: String) : + AddContactError + override fun onInit() { super.onInit() fetchHandshakeLink() } + private val _visible = mutableStateOf(false) private val _alias = mutableStateOf("") private val _remoteHandshakeLink = mutableStateOf("") private val _handshakeLink = mutableStateOf("") + private val _error = mutableStateOf<AddContactError?>(null) + val visible = _visible.asState() val alias = _alias.asState() val remoteHandshakeLink = _remoteHandshakeLink.asState() val handshakeLink = _handshakeLink.asState() + val error = _error.asState() + + fun showDialog() { + _visible.value = true + } + + fun dismissDialog() { + _visible.value = false + } fun setAddContactAlias(alias: String) { _alias.value = alias @@ -80,13 +106,17 @@ constructor( addPendingContact(link, alias) } + fun clearError() { + _error.value = null + } + private fun addPendingContact(link: String, alias: String) { // ignore preceding and trailing whitespace val matcher = HandshakeLinkConstants.LINK_REGEX.matcher(link.trim()) // check if the link is well-formed if (!matcher.matches()) { LOG.warn { "Remote handshake link is invalid" } - // TODO: show message to user + _error.value = RemoteInvalidError(link) return } // compare with own link @@ -94,25 +124,30 @@ constructor( val withSchema = "briar://$withoutSchema" if (_handshakeLink.value == withSchema) { LOG.warn { "Please enter contact's link, not your own" } - // TODO: show warning to user + _error.value = OwnLinkError(link) return } if (aliasIsInvalid(alias)) { LOG.warn { "Alias is invalid" } - // TODO: show message to user + _error.value = AliasInvalidError(link, alias) return } runOnDbThreadWithTransaction(false) { txn -> try { contactManager.addPendingContact(txn, link, alias) + txn.attach { + _visible.value = false + _alias.value = "" + _remoteHandshakeLink.value = "" + } } catch (e: FormatException) { - LOG.warn(e) { "Link is invalid" } - // TODO: show error to user + LOG.warn { "Link is invalid: $link" } + _error.value = LinkInvalidError(link) } catch (e: GeneralSecurityException) { - LOG.warn(e) { "Public key is invalid" } - // TODO: show error to user + LOG.warn { "Public key is invalid: $link" } + _error.value = PublicKeyInvalidError(link) } /* TODO: Warn user that the following two errors might be an attack @@ -122,11 +157,11 @@ constructor( */ catch (e: ContactExistsException) { - LOG.warn(e) { "Contact already exists" } - // TODO: show error to user + LOG.warn { "Contact already exists: $link" } + _error.value = ErrorContactAlreadyExists(link, e.remoteAuthor.name, alias) } catch (e: PendingContactExistsException) { - LOG.warn(e) { "Pending Contact already exists" } - // TODO: show error to user + LOG.warn { "Pending contact already exists: $link" } + _error.value = ErrorPendingAlreadyExists(link, e.pendingContact.alias, alias) } } } diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/PrivateMessageScreen.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/PrivateMessageScreen.kt index 5ac782b9fbc99d9ffd96cd3ba4d578c557882be3..aa94d0c9bd77334f8b83babe1a2e0302bbea6f5d 100644 --- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/PrivateMessageScreen.kt +++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/PrivateMessageScreen.kt @@ -37,10 +37,6 @@ import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -51,6 +47,7 @@ import org.briarproject.briar.desktop.contact.ContactListViewModel import org.briarproject.briar.desktop.contact.PendingContactIdWrapper import org.briarproject.briar.desktop.contact.RealContactIdWrapper import org.briarproject.briar.desktop.contact.add.remote.AddContactDialog +import org.briarproject.briar.desktop.contact.add.remote.AddContactViewModel import org.briarproject.briar.desktop.ui.BriarLogo import org.briarproject.briar.desktop.ui.Constants.PARAGRAPH_WIDTH import org.briarproject.briar.desktop.ui.VerticalDivider @@ -60,12 +57,12 @@ import org.briarproject.briar.desktop.viewmodel.viewModel @Composable fun PrivateMessageScreen( viewModel: ContactListViewModel = viewModel(), + addContactViewModel: AddContactViewModel = viewModel(), ) { - var isContactDialogVisible by remember { mutableStateOf(false) } - if (isContactDialogVisible) AddContactDialog(onClose = { isContactDialogVisible = false }) + AddContactDialog() if (viewModel.noContactsYet.value) { - NoContactsYet(onContactAdd = { isContactDialogVisible = true }) + NoContactsYet(onContactAdd = { addContactViewModel.showDialog() }) return } @@ -76,7 +73,7 @@ fun PrivateMessageScreen( viewModel::selectContact, viewModel.filterBy.value, viewModel::setFilterBy, - onContactAdd = { isContactDialogVisible = true } + onContactAdd = { addContactViewModel.showDialog() } ) VerticalDivider() Column(modifier = Modifier.weight(1f).fillMaxHeight()) { diff --git a/src/main/kotlin/org/briarproject/briar/desktop/dialogs/DialogType.kt b/src/main/kotlin/org/briarproject/briar/desktop/dialogs/DialogType.kt new file mode 100644 index 0000000000000000000000000000000000000000..180850ff913308c796fcbc3552afc4b929e05d31 --- /dev/null +++ b/src/main/kotlin/org/briarproject/briar/desktop/dialogs/DialogType.kt @@ -0,0 +1,24 @@ +/* + * Briar Desktop + * Copyright (C) 2021-2022 The Briar Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package org.briarproject.briar.desktop.dialogs + +enum class DialogType { + ERROR, + WARNING +} diff --git a/src/main/kotlin/org/briarproject/briar/desktop/theme/Colors.kt b/src/main/kotlin/org/briarproject/briar/desktop/theme/Colors.kt index 2cb6961e6323e663a009db8eae657e3a2ccab2c8..87d720e5931a511d69acae49de2ae869c89a3ba7 100644 --- a/src/main/kotlin/org/briarproject/briar/desktop/theme/Colors.kt +++ b/src/main/kotlin/org/briarproject/briar/desktop/theme/Colors.kt @@ -39,6 +39,7 @@ val Gray950 = Color(0xff1f1f1f) val materialDarkBg = Color(0xff121212) val Red500 = Color(0xffdb3b21) +val Orange500 = Color(0xfffc9403) val Blue400 = Color(0xff418cd8) val Blue500 = Color(0xff1f78d1) // todo: unused in Android diff --git a/src/main/kotlin/org/briarproject/briar/desktop/ui/Constants.kt b/src/main/kotlin/org/briarproject/briar/desktop/ui/Constants.kt index d860f80b8f4cdfe80313b67ff66793ca3c6bb38e..0101d6856398871a41711e66cf8691e126497f1b 100644 --- a/src/main/kotlin/org/briarproject/briar/desktop/ui/Constants.kt +++ b/src/main/kotlin/org/briarproject/briar/desktop/ui/Constants.kt @@ -25,4 +25,5 @@ object Constants { val COLUMN_WIDTH = 275.dp val STARTUP_FIELDS_WIDTH = 400.dp val PARAGRAPH_WIDTH = 540.dp + val DIALOG_WIDTH = 400.dp } diff --git a/src/main/kotlin/org/briarproject/briar/desktop/utils/PreviewUtils.kt b/src/main/kotlin/org/briarproject/briar/desktop/utils/PreviewUtils.kt index 0d0594748ba3be162ec6489b935f92e74911308f..d4337e68519e1b2e6af584b46e6c090b97496833 100644 --- a/src/main/kotlin/org/briarproject/briar/desktop/utils/PreviewUtils.kt +++ b/src/main/kotlin/org/briarproject/briar/desktop/utils/PreviewUtils.kt @@ -24,10 +24,14 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Slider @@ -39,8 +43,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.MutableState import androidx.compose.runtime.State +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.graphics.Color import androidx.compose.ui.unit.dp @@ -58,7 +65,7 @@ object PreviewUtils { val parameters = mutableMapOf<String, MutableState<Any>>() - private inline fun <reified T> getDatatype(name: String): T { + inline fun <reified T> getDatatype(name: String): T { val state = parameters[name] ?: throw IllegalArgumentException("No parameter found with name '$name'") if (state.value !is T) throw IllegalArgumentException("Parameter '$name' is not of type ${T::class.simpleName}") return state.value as T @@ -90,6 +97,8 @@ object PreviewUtils { fun setFloatParameter(name: String, value: Float) = setDatatype(name, value) + inline fun <reified T : Any> getGenericParameter(name: String) = getDatatype<T>(name) + fun getRandomId() = random.nextBytes(UniqueId.LENGTH) @Composable @@ -142,8 +151,50 @@ object PreviewUtils { } @Composable - private fun PreviewScope.addFloatSliderParameter(name: String, initial: FloatSlider) = addParameter(name, initial.initial) { value -> - Slider(value.value, { value.value = it }, valueRange = initial.min..initial.max, modifier = Modifier.width(400.dp)) + private fun PreviewScope.addFloatSliderParameter(name: String, initial: FloatSlider) = + addParameter(name, initial.initial) { value -> + Slider( + value.value, + { value.value = it }, + valueRange = initial.min..initial.max, + modifier = Modifier.width(400.dp) + ) + } + + @Composable + private fun <T> PreviewScope.addDropDownParameter( + name: String, + initial: Values<T>, + ) { + var expanded by remember { mutableStateOf(false) } + val items = initial.values + val initialValue = items[initial.initial] + var selectedIndex by remember { mutableStateOf(initial.initial) } + addParameter(name, initialValue!!) { value -> + Box(modifier = Modifier.fillMaxSize().wrapContentSize(Alignment.TopStart)) { + Text( + initial.toString(items[selectedIndex]), + modifier = Modifier.fillMaxWidth().clickable(onClick = { expanded = true }) + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.fillMaxWidth() + ) { + items.forEachIndexed { index, s -> + DropdownMenuItem( + onClick = { + selectedIndex = index + expanded = false + value.value = items[index]!! + } + ) { + Text(text = initial.toString(s)) + } + } + } + } + } } /** @@ -171,6 +222,7 @@ object PreviewUtils { is Long -> scope.addLongParameter(name, initial) is Float -> scope.addFloatParameter(name, initial) is FloatSlider -> scope.addFloatSliderParameter(name, initial) + is Values<*> -> scope.addDropDownParameter(name, initial) else -> throw IllegalArgumentException("Type ${initial::class.simpleName} is not supported for previewing.") } } @@ -193,4 +245,10 @@ object PreviewUtils { val min: Float, val max: Float, ) + + data class Values<T>( + val initial: Int, + val values: List<T>, + val toString: (T) -> String, + ) }