diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/DesktopCoreModule.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/DesktopCoreModule.kt index dc5a1fdd460da365a3aa487b36f98c436c469814..a1e3a66108fdf4ba536c231e42636a8c5760a43a 100644 --- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/DesktopCoreModule.kt +++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/DesktopCoreModule.kt @@ -20,10 +20,13 @@ package org.briarproject.briar.desktop import dagger.Module import dagger.Provides +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.asExecutor import kotlinx.coroutines.swing.Swing import org.briarproject.bramble.account.AccountModule +import org.briarproject.bramble.api.crypto.CryptoExecutor import org.briarproject.bramble.api.db.DatabaseConfig import org.briarproject.bramble.api.event.EventExecutor import org.briarproject.bramble.api.mailbox.MailboxDirectory @@ -144,6 +147,12 @@ internal class DesktopCoreModule( @UiExecutor fun provideUiExecutor(): Executor = Dispatchers.Swing.asExecutor() + @Provides + @Singleton + @CryptoExecutor + fun provideCryptoDispatcher(@CryptoExecutor executor: Executor): CoroutineDispatcher = + executor.asCoroutineDispatcher() + @Provides @Singleton fun provideBriarExecutors(briarExecutors: BriarExecutorsImpl): BriarExecutors = briarExecutors diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumsViewModel.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumsViewModel.kt index 3ae485767942513d04bd7c51f332bba2d6b2c2f8..8755aa850d50cbcab3ee722f07324ad19ac0fda9 100644 --- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumsViewModel.kt +++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumsViewModel.kt @@ -22,29 +22,46 @@ import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.briarproject.bramble.api.crypto.CryptoExecutor import org.briarproject.bramble.api.db.TransactionManager import org.briarproject.bramble.api.event.Event import org.briarproject.bramble.api.event.EventBus +import org.briarproject.bramble.api.identity.IdentityManager import org.briarproject.bramble.api.lifecycle.LifecycleManager import org.briarproject.bramble.api.sync.GroupId +import org.briarproject.bramble.api.sync.MessageId import org.briarproject.bramble.api.sync.event.GroupAddedEvent import org.briarproject.bramble.api.sync.event.GroupRemovedEvent +import org.briarproject.bramble.api.system.Clock import org.briarproject.briar.api.forum.ForumManager import org.briarproject.briar.client.MessageTreeImpl import org.briarproject.briar.desktop.threading.BriarExecutors +import org.briarproject.briar.desktop.threading.UiExecutor import org.briarproject.briar.desktop.utils.clearAndAddAll import org.briarproject.briar.desktop.viewmodel.EventListenerDbViewModel import org.briarproject.briar.desktop.viewmodel.asState +import java.lang.Long.max import javax.inject.Inject sealed class PostsState object Loading : PostsState() -class Loaded(private val messageTree: MessageTreeImpl<ThreadItem>) : PostsState() { +class Loaded( + val messageTree: MessageTreeImpl<ThreadItem>, + val scrollTo: MessageId? = null, +) : PostsState() { val posts: MutableList<ThreadItem> get() = messageTree.depthFirstOrder() } class ForumsViewModel @Inject constructor( private val forumManager: ForumManager, + private val identityManager: IdentityManager, + private val clock: Clock, + @CryptoExecutor private val cryptoDispatcher: CoroutineDispatcher, briarExecutors: BriarExecutors, lifecycleManager: LifecycleManager, db: TransactionManager, @@ -126,6 +143,36 @@ class ForumsViewModel @Inject constructor( _filterBy.value = filter } + @OptIn(DelicateCoroutinesApi::class) + fun createPost(groupItem: GroupItem, text: String, parentId: MessageId?) = GlobalScope.launch { + val author = runOnDbThread(false) { txn -> + identityManager.getLocalAuthor(txn) + } + val count = runOnDbThread(false) { txn -> + forumManager.getGroupCount(txn, groupItem.id) + } + val timestamp = max(count.latestMsgTime + 1, clock.currentTimeMillis()) + val post = withContext(cryptoDispatcher) { + forumManager.createLocalPost(groupItem.id, text, timestamp, parentId, author) + } + runOnDbThread(false) { txn -> + val header = forumManager.addLocalPost(txn, post) + txn.attach { + val item = ForumPostItem(header, text) + addItem(item, item.id) + } + } + } + + @UiExecutor + private fun addItem(item: ForumPostItem, scrollTo: MessageId? = null) { + // If items haven't loaded, we need to wait until they have. + // Since this was a R/W DB transaction, the load will pick up this item. + val tree = (posts.value as? Loaded)?.messageTree ?: return + tree.add(item) + _posts.value = Loaded(tree, scrollTo) + } + fun deleteGroup(groupItem: GroupItem) { forumManager.removeForum((groupItem as ForumsItem).forum) } diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupConversationScreen.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupConversationScreen.kt index 47ea90732eab3f4c404ad4c6ccd6696a3d8473b0..679d8a76f72a738733f0fb3cd792c1f8bc18c5d2 100644 --- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupConversationScreen.kt +++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupConversationScreen.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow.Companion.Ellipsis import androidx.compose.ui.unit.dp +import org.briarproject.bramble.api.sync.MessageId import org.briarproject.briar.desktop.contact.ContactDropDown.State.CLOSED import org.briarproject.briar.desktop.contact.ContactDropDown.State.MAIN import org.briarproject.briar.desktop.ui.Constants.HEADER_SIZE @@ -61,13 +62,23 @@ fun GroupConversationScreen( groupItem: GroupItem, viewModel: ForumsViewModel = viewModel(), ) { + val selectedPost = remember { mutableStateOf<MessageId?>(null) } Scaffold( topBar = { GroupConversationHeader(groupItem) { viewModel.deleteGroup(groupItem) } }, content = { padding -> - ThreadedConversationScreen(viewModel.posts.value) + ThreadedConversationScreen( + postsState = viewModel.posts.value, + selectedPost = selectedPost, + modifier = Modifier.padding(padding) + ) }, + bottomBar = { + GroupInputComposable(selectedPost) { text -> + viewModel.createPost(groupItem, text, selectedPost.value) + } + } ) } diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupInputComposable.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupInputComposable.kt new file mode 100644 index 0000000000000000000000000000000000000000..152c39e77b5d27cab8c007dcd8cc7c1becae2d9d --- /dev/null +++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupInputComposable.kt @@ -0,0 +1,104 @@ +/* + * 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.forums + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.material.TextFieldDefaults +import androidx.compose.material.TextFieldExt.moveFocusOnTab +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Send +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerIconDefaults +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.unit.dp +import org.briarproject.bramble.api.sync.MessageId +import org.briarproject.briar.api.forum.ForumConstants.MAX_FORUM_POST_TEXT_LENGTH +import org.briarproject.briar.desktop.theme.sendButton +import org.briarproject.briar.desktop.ui.HorizontalDivider +import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n + +@Composable +@OptIn(ExperimentalComposeUiApi::class) +fun GroupInputComposable( + selectedPost: State<MessageId?>, + onSend: (String) -> Unit, +) { + val postText = rememberSaveable { mutableStateOf("") } + val onSendAction = { + val text = postText.value + if (text.isNotBlank() && postText.value.length <= MAX_FORUM_POST_TEXT_LENGTH) { + onSend(text) + postText.value = "" + } + } + Column { + HorizontalDivider() + TextField( + value = postText.value, + onValueChange = { postText.value = it }, + onEnter = onSendAction, + maxLines = 10, + textStyle = MaterialTheme.typography.body1, + placeholder = { + Text( + text = if (selectedPost.value == null) { + i18n("forum.message.hint") + } else { + i18n("forum.message.replay.hint") + }, + style = MaterialTheme.typography.body1, + ) + }, + modifier = Modifier + .fillMaxWidth() + .moveFocusOnTab(), + shape = RoundedCornerShape(0.dp), + colors = TextFieldDefaults.textFieldColors( + backgroundColor = MaterialTheme.colors.background, + focusedIndicatorColor = MaterialTheme.colors.background, + unfocusedIndicatorColor = MaterialTheme.colors.background + ), + trailingIcon = { + IconButton( + icon = Icons.Filled.Send, + iconTint = MaterialTheme.colors.sendButton, + contentDescription = i18n("access.message.send"), + onClick = onSendAction, + modifier = Modifier + .padding(4.dp) + .size(32.dp) + .pointerHoverIcon(PointerIconDefaults.Default), + ) + } + ) + } +} diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupsCard.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupsCard.kt index c5f1fac5828be4489be6113b7dfcd8a07984c7f1..68f3a255c00d2a4d22de957c31674054dd3c89fc 100644 --- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupsCard.kt +++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupsCard.kt @@ -42,7 +42,7 @@ import org.briarproject.bramble.api.sync.GroupId import org.briarproject.briar.desktop.theme.selectedCard import org.briarproject.briar.desktop.theme.surfaceVariant import org.briarproject.briar.desktop.ui.Constants.HEADER_SIZE -import org.briarproject.briar.desktop.utils.InternationalizationUtils +import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18nP import org.briarproject.briar.desktop.utils.PreviewUtils import org.briarproject.briar.desktop.utils.TimeUtils.getFormattedTimestamp @@ -78,8 +78,8 @@ fun GroupsCard( .clickable(onClick = { onGroupItemSelected(item) }) .semantics { contentDescription = - if (selected) InternationalizationUtils.i18n("access.list.selected.yes") - else InternationalizationUtils.i18n("access.list.selected.no") + if (selected) i18n("access.list.selected.yes") + else i18n("access.list.selected.no") }, shape = RoundedCornerShape(0.dp), backgroundColor = if (selected) { diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ThreadedConversationScreen.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ThreadedConversationScreen.kt index 770e1ec6f5a2d1a73a886f2082abea88735ac020..679c9492f83d6f47d3126fc493ab144ae82e4a08 100644 --- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ThreadedConversationScreen.kt +++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ThreadedConversationScreen.kt @@ -19,38 +19,56 @@ package org.briarproject.briar.desktop.forums import androidx.compose.foundation.VerticalScrollbar +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollbarAdapter +import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment.Companion.CenterEnd import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.briarproject.bramble.api.sync.MessageId import org.briarproject.briar.desktop.forums.ThreadItem.Companion.UNDEFINED +import org.briarproject.briar.desktop.theme.selectedCard +import org.briarproject.briar.desktop.theme.surfaceVariant import org.briarproject.briar.desktop.ui.HorizontalDivider import org.briarproject.briar.desktop.ui.Loader @Composable fun ThreadedConversationScreen( postsState: PostsState, + selectedPost: MutableState<MessageId?>, + modifier: Modifier = Modifier ) = when (postsState) { Loading -> Loader() is Loaded -> { val scrollState = rememberLazyListState() - Box(modifier = Modifier.fillMaxSize()) { + val coroutineScope = rememberCoroutineScope() + // scroll to item if needed + if (postsState.scrollTo != null) coroutineScope.launch { + val index = postsState.posts.indexOfFirst { it.id == postsState.scrollTo } + if (index != -1) scrollState.scrollToItem(index, -50) + } + Box(modifier = modifier.fillMaxSize()) { LazyColumn( state = scrollState, modifier = Modifier.selectableGroup() ) { items(postsState.posts) { item -> - ThreadItemComposable(item) + ThreadItemComposable(item, selectedPost) HorizontalDivider() } } @@ -63,10 +81,22 @@ fun ThreadedConversationScreen( } @Composable -fun ThreadItemComposable(item: ThreadItem) { +fun ThreadItemComposable(item: ThreadItem, selectedPost: MutableState<MessageId?>) { + val isSelected = selectedPost.value == item.id Text( - text = item.text ?: "", + text = item.text, modifier = Modifier + .fillMaxWidth() + .background( + if (isSelected) { + MaterialTheme.colors.selectedCard + } else { + MaterialTheme.colors.surfaceVariant + } + ).selectable( + selected = isSelected, + onClick = { selectedPost.value = item.id } + ) .padding(4.dp) .padding( start = 4.dp + diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/DbViewModel.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/DbViewModel.kt index 73d439337dd4bc004c13cc29a5eb88f1c8f2a3ee..b82cb09a2fa6579730447c577bcf455dda25fd20 100644 --- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/DbViewModel.kt +++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/DbViewModel.kt @@ -20,10 +20,13 @@ package org.briarproject.briar.desktop.viewmodel import mu.KotlinLogging import org.briarproject.bramble.api.db.DatabaseExecutor +import org.briarproject.bramble.api.db.DbCallable import org.briarproject.bramble.api.db.Transaction import org.briarproject.bramble.api.db.TransactionManager import org.briarproject.bramble.api.lifecycle.LifecycleManager import org.briarproject.briar.desktop.threading.BriarExecutors +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine abstract class DbViewModel( private val briarExecutors: BriarExecutors, @@ -53,4 +56,14 @@ abstract class DbViewModel( readOnly: Boolean, task: (Transaction) -> Unit ) = briarExecutors.onDbThreadWithTransaction(readOnly, task) + + protected suspend fun <T> runOnDbThread( + readOnly: Boolean, + task: (Transaction) -> T + ) = suspendCoroutine<T> { cont -> + briarExecutors.onDbThread { + val t = db.transactionWithResult(readOnly, DbCallable { txn -> task(txn) }) + cont.resume(t) + } + } } diff --git a/briar-desktop/src/main/resources/strings/BriarDesktop.properties b/briar-desktop/src/main/resources/strings/BriarDesktop.properties index 78c9583eb9c43c53c2864f81fd5d2715ffc87f67..28a7b003cd1691f9bb38fa39dff7a96fd20b5427 100644 --- a/briar-desktop/src/main/resources/strings/BriarDesktop.properties +++ b/briar-desktop/src/main/resources/strings/BriarDesktop.properties @@ -114,6 +114,8 @@ forum.leave.title=Leave Forum forum.delete.dialog.title=Confirm Leaving Forum forum.delete.dialog.message=Are you sure that you want to leave this forum?\n\nAny contacts you\'ve shared this forum with might stop receiving updates. forum.delete.dialog.button=Leave +forum.message.hint=New Post +forum.message.replay.hint=New Reply group.card.no_posts=No posts group.card.posts={0, plural, one {{0} post} other {{0} posts}}