diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationList.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationList.kt index ddc487fe0bcad4614588440dd74a5f410b3ece1e..23ebc06716f94016e5f1174fd912f61cb6e90890 100644 --- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationList.kt +++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationList.kt @@ -4,16 +4,19 @@ import androidx.compose.foundation.VerticalScrollbar import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollbarAdapter import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button import androidx.compose.material.FloatingActionButton import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme @@ -43,6 +46,7 @@ import org.briarproject.briar.desktop.ui.Loader import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n import org.briarproject.briar.desktop.utils.PreviewUtils.preview import org.briarproject.briar.desktop.utils.replaceIfIndexed +import org.briarproject.briar.desktop.viewmodel.SingleStateEvent import java.time.Instant fun main() = preview( @@ -52,6 +56,8 @@ fun main() = preview( val numMessages = getIntParameter("num_messages") val initialFirstUnreadIndex = getIntParameter("first_unread_index") + val onMessageAddedToBottom = remember { SingleStateEvent<ConversationViewModel.MessageAddedType>() } + // re-create messages and currentUnreadMessageInfo as soon as numMessages or initialFirstUnreadIndex change val loading by produceState(true, numMessages, initialFirstUnreadIndex) { value = true; delay(500); value = false } @@ -89,20 +95,44 @@ fun main() = preview( return@preview } - ConversationList( - padding = PaddingValues(0.dp), - messages = messages, - initialFirstUnreadMessageIndex = initialFirstUnreadIndex, - currentUnreadMessagesInfo = currentUnreadMessagesInfo, - markMessagesRead = { lst -> - messages.replaceIfIndexed( - { idx, it -> idx in lst && !it.isRead }, - { _, it -> it.markRead() } - ) - }, - respondToRequest = { _, _ -> }, - deleteMessage = {} - ) + Column { + Button( + onClick = { + messages.add( + ConversationMessageItem( + text = "Extra Message", + id = MessageId(getRandomId()), + groupId = GroupId(getRandomId()), + time = Instant.now().toEpochMilli(), + autoDeleteTimer = 0, + isIncoming = true, + isRead = false, + isSent = false, + isSeen = false + ) + ) + onMessageAddedToBottom.emit(ConversationViewModel.MessageAddedType.INCOMING) + } + ) { + Text("Add new incoming message to bottom") + } + + ConversationList( + padding = PaddingValues(0.dp), + messages = messages, + initialFirstUnreadMessageIndex = initialFirstUnreadIndex, + currentUnreadMessagesInfo = currentUnreadMessagesInfo, + onMessageAddedToBottom = onMessageAddedToBottom, + markMessagesRead = { lst -> + messages.replaceIfIndexed( + { idx, it -> idx in lst && !it.isRead }, + { _, it -> it.markRead() } + ) + }, + respondToRequest = { _, _ -> }, + deleteMessage = {}, + ) + } } @Composable @@ -111,6 +141,7 @@ fun ConversationList( messages: List<ConversationItem>, initialFirstUnreadMessageIndex: Int, currentUnreadMessagesInfo: ConversationViewModel.UnreadMessagesInfo, + onMessageAddedToBottom: SingleStateEvent<ConversationViewModel.MessageAddedType>, markMessagesRead: (List<Int>) -> Unit, respondToRequest: (ConversationRequestItem, Boolean) -> Unit, deleteMessage: (MessageId) -> Unit, @@ -129,19 +160,20 @@ fun ConversationList( LazyColumn( state = scrollState, contentPadding = PaddingValues(top = 8.dp, bottom = 8.dp), - modifier = Modifier.fillMaxSize().padding(end = 12.dp) + modifier = Modifier.fillMaxSize().padding(end = 12.dp, top = 8.dp, bottom = 8.dp) ) { itemsIndexed(messages) { idx, m -> if (idx == initialFirstUnreadMessageIndex) { UnreadMessagesMarker() } when (m) { - is ConversationMessageItem -> ConversationMessageItemView(m) - is ConversationNoticeItem -> ConversationNoticeItemView(m) + is ConversationMessageItem -> ConversationMessageItemView(m, deleteMessage) + is ConversationNoticeItem -> ConversationNoticeItemView(m, deleteMessage) is ConversationRequestItem -> ConversationRequestItemView( m, onResponse = { accept -> respondToRequest(m, accept) }, + onDelete = deleteMessage ) } } @@ -178,6 +210,15 @@ fun ConversationList( } } } + + onMessageAddedToBottom.react { type -> + // scroll to bottom for new *outgoing* message or if scroll position was at last message before + if (type == ConversationViewModel.MessageAddedType.OUTGOING || scrollState.isScrolledToPenultimate()) { + scope.launch { + scrollState.animateScrollToItem(messages.lastIndex) + } + } + } } @Composable @@ -211,3 +252,9 @@ fun UnreadMessagesFAB( Modifier.align(Alignment.TopEnd).offset(3.dp, (-3).dp) ) } + +fun LazyListState.isScrolledToPenultimate(): Boolean { + val last = layoutInfo.visibleItemsInfo.lastOrNull() ?: return false + return last.index == layoutInfo.totalItemsCount - 1 && + last.offset == layoutInfo.viewportEndOffset +} diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationScreen.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationScreen.kt index f7eff442c0f8830cbae81417243a7ac24bff9597..62993a1ed647111d9bdb880c0aebe1e6aa502f46 100644 --- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationScreen.kt +++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationScreen.kt @@ -78,6 +78,7 @@ fun ConversationScreen( viewModel.messages, viewModel.initialFirstUnreadMessageIndex.value, viewModel.currentUnreadMessagesInfo.value, + viewModel.onMessageAddedToBottom, viewModel::markMessagesRead, viewModel::respondToRequest, viewModel::deleteMessage, diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationViewModel.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationViewModel.kt index 969423ab5a62c6fcba468ca55aa675fa1d73c3af..f7054811ea0892f2bebfea68e8eb2794d0f8e9bc 100644 --- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationViewModel.kt +++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationViewModel.kt @@ -45,6 +45,7 @@ import org.briarproject.briar.desktop.utils.clearAndAddAll import org.briarproject.briar.desktop.utils.replaceIf import org.briarproject.briar.desktop.utils.replaceIfIndexed import org.briarproject.briar.desktop.viewmodel.EventListenerDbViewModel +import org.briarproject.briar.desktop.viewmodel.SingleStateEvent import org.briarproject.briar.desktop.viewmodel.asList import org.briarproject.briar.desktop.viewmodel.asState import javax.inject.Inject @@ -133,7 +134,7 @@ constructor( ) val visitor = ConversationVisitor(contactItem.value!!.name, messagingManager, txn) val msg = h.accept(visitor)!! - txn.attach { _messages.add(msg) } + txn.attach { addMessage(msg) } } catch (e: UnexpectedTimerException) { // todo: handle this properly LOG.warn(e) {} @@ -162,6 +163,10 @@ constructor( val firstIndex: Int ) + val onMessageAddedToBottom = SingleStateEvent<MessageAddedType>() + + enum class MessageAddedType { OUTGOING, INCOMING } + fun markMessagesRead(indices: List<Int>) { val id = _contactId.value!! val messages = _messages.toList() @@ -259,7 +264,7 @@ constructor( runOnDbThreadWithTransaction(true) { txn -> val visitor = ConversationVisitor(contactItem.value!!.name, messagingManager, txn) val msg = h.accept(visitor)!! - txn.attach { _messages.add(msg) } + txn.attach { addMessage(msg) } } } } @@ -302,6 +307,14 @@ constructor( } } + private fun addMessage(msg: ConversationItem) { + // currently this method adds the message always at the end + // todo: instead we should check where to insert it to maintain the timely order + _messages.add(msg) + val type = if (msg.isIncoming) MessageAddedType.INCOMING else MessageAddedType.OUTGOING + onMessageAddedToBottom.emit(type) + } + private fun markMessages( messageIds: Collection<MessageId>, sent: Boolean, diff --git a/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/SingleStateEvent.kt b/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/SingleStateEvent.kt new file mode 100644 index 0000000000000000000000000000000000000000..72231fb872eeb9eac8c6d6dbe7a7261c71d1eb7d --- /dev/null +++ b/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/SingleStateEvent.kt @@ -0,0 +1,43 @@ +package org.briarproject.briar.desktop.viewmodel + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf + +/** + * An event to be emitted from anywhere and reacted to in a Composable. + * The class is backed by a [MutableState] and thus thread-safe. + * <p> + * Note that only one Composable will be able to react to the event, + * trying to react to it from multiple places is considered a bug. + * <p> + * As emitting one-time events instead of updating state goes against + * the declarative programming paradigm of Compose, + * only use this class if you are sure that you actually need it. + */ +class SingleStateEvent<T : Any> { + private var state = mutableStateOf<T?>(null) + + /** + * Emit a new value of type [T] for this event. + */ + fun emit(value: T) { + state.value = value + } + + /** + * React to every new value of type [T] emitted through this event. + * Make sure to not react to the same event on multiple places. + */ + @Composable + fun react(block: (T) -> Unit) { + LaunchedEffect(state.value) { + val value = state.value + if (value != null) { + block(value) + state.value = null + } + } + } +}