diff --git a/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactList.kt b/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactList.kt index 7fa969a5b36d8be169920ee6e3b99c961f6c1ee7..06913697cf7cf93bbf2f75173a4bd20b0883fbcc 100644 --- a/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactList.kt +++ b/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactList.kt @@ -48,8 +48,8 @@ fun ContactList( items(viewModel.contactList) { contactItem -> ContactCard( contactItem, - { viewModel.selectContact(contactItem.contact) }, - viewModel.isSelected(contactItem.contact) + { viewModel.selectContact(contactItem.contact.id) }, + viewModel.isSelected(contactItem.contact.id) ) } } diff --git a/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactListViewModel.kt b/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactListViewModel.kt index ee4a7f8c82a3cb0c8a86c53c409169e8f54193bb..a7769fe4a7d25a89579a01c76844b4215dc8d82b 100644 --- a/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactListViewModel.kt +++ b/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactListViewModel.kt @@ -4,12 +4,14 @@ import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import org.briarproject.bramble.api.connection.ConnectionRegistry import org.briarproject.bramble.api.contact.Contact +import org.briarproject.bramble.api.contact.ContactId import org.briarproject.bramble.api.contact.ContactManager import org.briarproject.bramble.api.contact.event.ContactAliasChangedEvent import org.briarproject.bramble.api.event.Event import org.briarproject.bramble.api.event.EventBus import org.briarproject.briar.api.conversation.ConversationManager import org.briarproject.briar.api.conversation.event.ConversationMessageReceivedEvent +import org.briarproject.briar.desktop.conversation.ConversationMessageToBeSentEvent import java.util.logging.Logger import javax.inject.Inject @@ -32,16 +34,16 @@ constructor( } private val _filterBy = mutableStateOf("") - private val _selectedContact = mutableStateOf<Contact?>(null) + private val _selectedContactId = mutableStateOf<ContactId?>(null) val filterBy: State<String> = _filterBy - val selectedContact: State<Contact?> = _selectedContact + val selectedContactId: State<ContactId?> = _selectedContactId - fun selectContact(contact: Contact) { - _selectedContact.value = contact + fun selectContact(contactId: ContactId) { + _selectedContactId.value = contactId } - fun isSelected(contact: Contact) = _selectedContact.value?.id == contact.id + fun isSelected(contactId: ContactId) = _selectedContactId.value == contactId override fun filterContact(contact: Contact) = // todo: also filter on alias @@ -54,7 +56,11 @@ constructor( override fun updateFilteredList() { super.updateFilteredList() - _selectedContact.value?.let { if (!filterContact(it)) _selectedContact.value = null } + + _selectedContactId.value?.let { id -> + if (!contactList.map { it.contact.id }.contains(id)) + _selectedContactId.value = null + } } override fun eventOccurred(e: Event?) { @@ -64,6 +70,10 @@ constructor( LOG.info("Conversation message received, updating item") updateItem(e.contactId) { it.updateFromMessageHeader(e.messageHeader) } } + is ConversationMessageToBeSentEvent -> { + LOG.info("Conversation message added, updating item") + updateItem(e.contactId) { it.updateFromMessageHeader(e.messageHeader) } + } // is AvatarUpdatedEvent -> {} is ContactAliasChangedEvent -> { updateItem(e.contactId) { it.updateAlias(e.alias) } diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/Chat.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/Chat.kt deleted file mode 100644 index 9735b9968873dc9dfeae814371f82bfb4d19562e..0000000000000000000000000000000000000000 --- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/Chat.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.briarproject.briar.desktop.conversation - -import org.briarproject.briar.desktop.utils.TimeUtils.getFormattedTimestamp - -class Chat { - - val messages: MutableList<SimpleMessage> = ArrayList() - - fun appendMessage(local: Boolean, timestamp: Long, messageText: String?) { - val author = if (local) "You" else "Other" - val formattedTimestamp = getFormattedTimestamp(timestamp) - messages.add(SimpleMessage(local, author, messageText!!, formattedTimestamp, true)) - } -} diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ChatHistoryConversationVisitor.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ChatHistoryConversationVisitor.kt deleted file mode 100644 index 4163d16c74135220a6766664988888d33e91ef2f..0000000000000000000000000000000000000000 --- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ChatHistoryConversationVisitor.kt +++ /dev/null @@ -1,101 +0,0 @@ -package org.briarproject.briar.desktop.conversation - -import org.briarproject.bramble.api.db.DbException -import org.briarproject.bramble.util.LogUtils -import org.briarproject.briar.api.blog.BlogInvitationRequest -import org.briarproject.briar.api.blog.BlogInvitationResponse -import org.briarproject.briar.api.conversation.ConversationMessageHeader -import org.briarproject.briar.api.conversation.ConversationMessageVisitor -import org.briarproject.briar.api.forum.ForumInvitationRequest -import org.briarproject.briar.api.forum.ForumInvitationResponse -import org.briarproject.briar.api.introduction.IntroductionRequest -import org.briarproject.briar.api.introduction.IntroductionResponse -import org.briarproject.briar.api.messaging.MessagingManager -import org.briarproject.briar.api.messaging.PrivateMessageHeader -import org.briarproject.briar.api.privategroup.invitation.GroupInvitationRequest -import org.briarproject.briar.api.privategroup.invitation.GroupInvitationResponse -import java.util.logging.Level.WARNING -import java.util.logging.Logger - -class ChatHistoryConversationVisitor( - private val chat: Chat, - private val messagingManager: MessagingManager -) : - ConversationMessageVisitor<Void?> { - - companion object { - private val LOG = Logger.getLogger(ChatHistoryConversationVisitor::class.java.name) - } - - private fun appendMessage(header: ConversationMessageHeader) { - try { - val messageText = messagingManager.getMessageText(header.id) - chat.appendMessage(header.isLocal, header.timestamp, messageText) - } catch (e: DbException) { - LOG.warning("Error while getting message text") - LogUtils.logException(LOG, WARNING, e) - } - } - - override fun visitPrivateMessageHeader(h: PrivateMessageHeader): Void? { - appendMessage(h) - return null - } - - override fun visitBlogInvitationRequest(r: BlogInvitationRequest): Void? { - return null - } - - override fun visitBlogInvitationResponse(r: BlogInvitationResponse): Void? { - return null - } - - override fun visitForumInvitationRequest(r: ForumInvitationRequest): Void? { - return null - } - - override fun visitForumInvitationResponse(r: ForumInvitationResponse): Void? { - return null - } - - override fun visitGroupInvitationRequest(r: GroupInvitationRequest): Void? { - return null - } - - override fun visitGroupInvitationResponse(r: GroupInvitationResponse): Void? { - return null - } - - override fun visitIntroductionRequest(r: IntroductionRequest): Void? { - chat.appendMessage( - r.isLocal, r.timestamp, - String.format( - "You received an introduction request! Username: %s, Message: %s", - r.name, r.text - ) - ) - if (!r.wasAnswered()) { - chat.appendMessage( - r.isLocal, r.timestamp, - "Do you accept the invitation?" - ) - // TODO chat.appendYesNoButtons(r); - } - return null - } - - override fun visitIntroductionResponse(r: IntroductionResponse): Void? { - if (r.wasAccepted()) { - chat.appendMessage( - r.isLocal, r.timestamp, - "You accepted the request" - ) - } else { - chat.appendMessage( - r.isLocal, r.timestamp, - "You declined the request" - ) - } - return null - } -} diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ChatState.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ChatState.kt deleted file mode 100644 index e996fd151858c8b9898d499d584aeadfa9a7cf71..0000000000000000000000000000000000000000 --- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ChatState.kt +++ /dev/null @@ -1,37 +0,0 @@ -package org.briarproject.briar.desktop.conversation - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import org.briarproject.bramble.api.contact.ContactId -import org.briarproject.briar.api.conversation.ConversationMessageHeader -import org.briarproject.briar.desktop.ui.CVM -import org.briarproject.briar.desktop.ui.MM -import org.briarproject.briar.desktop.ui.UiState -import java.util.Collections - -@Composable -fun ChatState(id: ContactId): MutableState<UiState<Chat>> { - val state: MutableState<UiState<Chat>> = remember { mutableStateOf(UiState.Loading) } - val messagingManager = MM.current - val conversationManager = CVM.current - - DisposableEffect(id) { - state.value = UiState.Loading - val chat = Chat() - val visitor = ChatHistoryConversationVisitor(chat, messagingManager) - val messageHeaders: List<ConversationMessageHeader> = ArrayList(conversationManager.getMessageHeaders(id)) - Collections.sort(messageHeaders, ConversationMessageHeaderComparator()) - // Reverse order here because we're using reverseLayout=true on the LazyColumn to display items - // from bottom to top - Collections.reverse(messageHeaders) - for (header in messageHeaders) { - header.accept(visitor) - } - state.value = UiState.Success(chat) - onDispose { } - } - return state -} diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/Conversation.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/Conversation.kt index 23c61666c1115c531806d12bec299ece7df21be9..dee4283f89c6ef0877e195600cb87168b5b0abda 100644 --- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/Conversation.kt +++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/Conversation.kt @@ -4,38 +4,56 @@ import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints 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.layout.requiredSize import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import org.briarproject.bramble.api.contact.Contact +import org.briarproject.bramble.api.contact.ContactId import org.briarproject.briar.desktop.contact.ContactInfoDrawer import org.briarproject.briar.desktop.contact.ContactInfoDrawerState import org.briarproject.briar.desktop.introduction.IntroductionViewModel import org.briarproject.briar.desktop.navigation.SIDEBAR_WIDTH import org.briarproject.briar.desktop.theme.surfaceVariant import org.briarproject.briar.desktop.ui.Constants.CONTACTLIST_WIDTH +import org.briarproject.briar.desktop.ui.Loader @Composable fun Conversation( - contact: Contact, + contactId: ContactId, + conversationViewModel: ConversationViewModel, introductionViewModel: IntroductionViewModel, ) { + LaunchedEffect(contactId) { + conversationViewModel.setContactId(contactId) + } + + val contactItem = conversationViewModel.contactItem.value + + if (contactItem == null) { + Loader() + return + } + val (infoDrawer, setInfoDrawer) = remember { mutableStateOf(false) } val (contactDrawerState, setDrawerState) = remember { mutableStateOf(ContactInfoDrawerState.MakeIntro) } BoxWithConstraints(Modifier.fillMaxSize()) { @@ -43,10 +61,10 @@ fun Conversation( Scaffold( topBar = { ConversationHeader( - contact, + contactItem, onMakeIntroduction = { introductionViewModel.apply { - setFirstContact(contact) + setFirstContact(contactItem.contact) loadContacts() } setInfoDrawer(true) @@ -54,12 +72,26 @@ fun Conversation( ) }, content = { padding -> - Box(modifier = Modifier.padding(padding)) { - val chat = ChatState(contact.id) - TextBubbles(chat.value) + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + // reverseLayout to display most recent message (index 0) at the bottom + reverseLayout = true, + contentPadding = PaddingValues(8.dp), + modifier = Modifier.padding(padding).fillMaxHeight() + ) { + items(conversationViewModel.messages) { m -> + if (m is ConversationMessageItem) + TextBubble(m) + } } }, - bottomBar = { ConversationInput() }, + bottomBar = { + ConversationInput( + conversationViewModel.newMessage.value, + conversationViewModel::setNewMessage, + conversationViewModel::sendMessage + ) + }, ) if (infoDrawer) { // TODO Find non-hacky way of setting scrim on entire app diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationHeader.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationHeader.kt index a36dca368671dfc5302184dab43c84e45e50a6a5..7a92663a4f2275af8d431a605b3b40cc2d474501 100644 --- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationHeader.kt +++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationHeader.kt @@ -21,26 +21,27 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.drawscope.withTransform import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import org.briarproject.bramble.api.contact.Contact import org.briarproject.briar.desktop.contact.ContactDropDown +import org.briarproject.briar.desktop.contact.ContactItem import org.briarproject.briar.desktop.contact.ProfileCircle import org.briarproject.briar.desktop.theme.outline +import org.briarproject.briar.desktop.theme.surfaceVariant import org.briarproject.briar.desktop.ui.Constants.HEADER_SIZE import org.briarproject.briar.desktop.ui.HorizontalDivider @Composable fun ConversationHeader( - contact: Contact, + contactItem: ContactItem, onMakeIntroduction: () -> Unit, ) { val (isExpanded, setExpanded) = remember { mutableStateOf(false) } - // TODO hook up online indicator logic - val onlineColor = MaterialTheme.colors.secondary + val onlineColor = + if (contactItem.isConnected) MaterialTheme.colors.secondary else MaterialTheme.colors.surfaceVariant val outlineColor = MaterialTheme.colors.outline Box(modifier = Modifier.fillMaxWidth().height(HEADER_SIZE + 1.dp)) { Row(modifier = Modifier.align(Alignment.Center)) { - ProfileCircle(36.dp, contact.author.id.bytes) + ProfileCircle(36.dp, contactItem.contact.author.id.bytes) Canvas( modifier = Modifier.align(Alignment.CenterVertically), onDraw = { @@ -52,7 +53,7 @@ fun ConversationHeader( } ) Text( - contact.author.name, + contactItem.contact.author.name, modifier = Modifier.align(Alignment.CenterVertically).padding(start = 12.dp), fontSize = 20.sp ) diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationInput.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationInput.kt index 54eef33b668b01b81873976936c8f7bd2c9dbd36..dcfa6d755ff7af6a179a2a4975f0c68b575e0d30 100644 --- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationInput.kt +++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationInput.kt @@ -19,10 +19,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Send 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.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle @@ -36,19 +32,18 @@ import org.briarproject.briar.desktop.ui.HorizontalDivider fun PreviewConversationInput() { MaterialTheme(colors = DarkColors) { Surface { - ConversationInput() + ConversationInput("Lorem ipsum.", {}, {}) } } } @Composable -fun ConversationInput() { - var text by remember { mutableStateOf("") } +fun ConversationInput(text: String, updateText: (String) -> Unit, onSend: () -> Unit) { Column { HorizontalDivider() TextField( value = text, - onValueChange = { text = it }, + onValueChange = updateText, maxLines = 10, textStyle = TextStyle(fontSize = 16.sp, lineHeight = 16.sp), placeholder = { Text("Message") }, @@ -70,7 +65,7 @@ fun ConversationInput() { }, trailingIcon = { IconButton( - onClick = { }, modifier = Modifier.padding(4.dp).size(32.dp), + onClick = onSend, modifier = Modifier.padding(4.dp).size(32.dp), ) { Icon( Icons.Filled.Send, diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationItem.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationItem.kt new file mode 100644 index 0000000000000000000000000000000000000000..bc7b2959e2427b8dcd169ad2ce2b4a926ed2eec8 --- /dev/null +++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationItem.kt @@ -0,0 +1,31 @@ +package org.briarproject.briar.desktop.conversation + +import org.briarproject.bramble.api.sync.GroupId +import org.briarproject.bramble.api.sync.MessageId + +sealed class ConversationItem { + abstract val id: MessageId + abstract val groupId: GroupId + abstract val time: Long + abstract val autoDeleteTimer: Long + abstract val isIncoming: Boolean + + /** + * Only useful for incoming messages. + */ + abstract val isRead: Boolean + + /** + * Only useful for outgoing messages. + */ + abstract val isSent: Boolean + + /** + * Only useful for outgoing messages. + */ + abstract val isSeen: Boolean + + abstract fun mark(sent: Boolean, seen: Boolean): ConversationItem + + abstract fun markRead(): ConversationItem +} diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationMessageHeaderComparator.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationMessageHeaderComparator.kt deleted file mode 100644 index 1579924735705b1baa24002a27dae53ea947595a..0000000000000000000000000000000000000000 --- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationMessageHeaderComparator.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.briarproject.briar.desktop.conversation - -import org.briarproject.briar.api.conversation.ConversationMessageHeader - -class ConversationMessageHeaderComparator : Comparator<ConversationMessageHeader> { - - override fun compare( - h1: ConversationMessageHeader, - h2: ConversationMessageHeader - ): Int { - return h1.timestamp.compareTo(h2.timestamp) - } -} diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationMessageItem.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationMessageItem.kt new file mode 100644 index 0000000000000000000000000000000000000000..2d48f76bb69eae1a3b8a75ab7b8683008c927436 --- /dev/null +++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationMessageItem.kt @@ -0,0 +1,41 @@ +package org.briarproject.briar.desktop.conversation + +import org.briarproject.bramble.api.sync.GroupId +import org.briarproject.bramble.api.sync.MessageId +import org.briarproject.briar.api.conversation.ConversationMessageHeader + +data class ConversationMessageItem( + var text: String? = null, + override val id: MessageId, + override val groupId: GroupId, + override val time: Long, + override val autoDeleteTimer: Long, + override val isIncoming: Boolean, + override var isRead: Boolean, + override var isSent: Boolean, + override var isSeen: Boolean, + + // todo: support attachments + // val attachments: List<AttachmentItem> +) : ConversationItem() { + + constructor(h: ConversationMessageHeader) : + this( + id = h.id, + groupId = h.groupId, + time = h.timestamp, + autoDeleteTimer = h.autoDeleteTimer, + isRead = h.isRead, + isSent = h.isSent, + isSeen = h.isSeen, + isIncoming = !h.isLocal, + ) + + override fun mark(sent: Boolean, seen: Boolean): ConversationItem { + return copy(isSent = sent, isSeen = seen) + } + + override fun markRead(): ConversationItem { + return copy(isRead = true) + } +} diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationMessageToBeSentEvent.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationMessageToBeSentEvent.kt new file mode 100644 index 0000000000000000000000000000000000000000..e79b0362858cb663877bbf919806ae176372f403 --- /dev/null +++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationMessageToBeSentEvent.kt @@ -0,0 +1,13 @@ +package org.briarproject.briar.desktop.conversation + +import org.briarproject.bramble.api.contact.ContactId +import org.briarproject.bramble.api.event.Event +import org.briarproject.briar.api.conversation.ConversationMessageHeader + +/** + * An event that is broadcast when a new conversation message to be sent is added. + */ +data class ConversationMessageToBeSentEvent( + val messageHeader: ConversationMessageHeader, + val contactId: ContactId +) : Event() diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationViewModel.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationViewModel.kt new file mode 100644 index 0000000000000000000000000000000000000000..8d9902e240f3f07a51a1c550a185055f42704e25 --- /dev/null +++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationViewModel.kt @@ -0,0 +1,234 @@ +package org.briarproject.briar.desktop.conversation + +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import org.briarproject.bramble.api.FormatException +import org.briarproject.bramble.api.connection.ConnectionRegistry +import org.briarproject.bramble.api.contact.ContactId +import org.briarproject.bramble.api.contact.ContactManager +import org.briarproject.bramble.api.contact.event.ContactRemovedEvent +import org.briarproject.bramble.api.db.DbException +import org.briarproject.bramble.api.db.NoSuchContactException +import org.briarproject.bramble.api.event.Event +import org.briarproject.bramble.api.event.EventBus +import org.briarproject.bramble.api.event.EventListener +import org.briarproject.bramble.api.plugin.event.ContactConnectedEvent +import org.briarproject.bramble.api.plugin.event.ContactDisconnectedEvent +import org.briarproject.bramble.api.sync.MessageId +import org.briarproject.bramble.api.sync.event.MessagesAckedEvent +import org.briarproject.bramble.api.sync.event.MessagesSentEvent +import org.briarproject.bramble.api.versioning.event.ClientVersionUpdatedEvent +import org.briarproject.bramble.util.LogUtils +import org.briarproject.briar.api.autodelete.UnexpectedTimerException +import org.briarproject.briar.api.autodelete.event.ConversationMessagesDeletedEvent +import org.briarproject.briar.api.conversation.ConversationManager +import org.briarproject.briar.api.conversation.event.ConversationMessageReceivedEvent +import org.briarproject.briar.api.messaging.MessagingManager +import org.briarproject.briar.api.messaging.PrivateMessage +import org.briarproject.briar.api.messaging.PrivateMessageFactory +import org.briarproject.briar.api.messaging.PrivateMessageHeader +import org.briarproject.briar.desktop.contact.ContactItem +import org.briarproject.briar.desktop.utils.replaceIf +import java.util.Date +import java.util.logging.Level +import java.util.logging.Logger +import javax.inject.Inject + +class ConversationViewModel +@Inject +constructor( + private val connectionRegistry: ConnectionRegistry, + private val contactManager: ContactManager, + private val conversationManager: ConversationManager, + private val messagingManager: MessagingManager, + private val eventBus: EventBus, + private val privateMessageFactory: PrivateMessageFactory, +) : EventListener { + + companion object { + private val LOG = Logger.getLogger(ConversationViewModel::class.java.name) + } + + init { + // todo: where/when to remove listener again? + eventBus.addListener(this) + } + + private val _contactId = mutableStateOf<ContactId?>(null) + private val _contactItem = mutableStateOf<ContactItem?>(null) + private val _messages = mutableStateListOf<ConversationItem>() + + private val _newMessage = mutableStateOf("") + + val contactItem: State<ContactItem?> = _contactItem + val messages: List<ConversationItem> = _messages + + val newMessage: State<String> = _newMessage + + fun setContactId(id: ContactId) { + _contactId.value = id + _contactItem.value = ContactItem( + contactManager.getContact(id), + connectionRegistry.isConnected(id), + conversationManager.getGroupCount(id), + ) + loadMessages() + } + + fun setNewMessage(msg: String) { + _newMessage.value = msg + } + + fun sendMessage() { + try { + val text = _newMessage.value + _newMessage.value = "" + + val start = LogUtils.now() + val m = createMessage(text) + messagingManager.addLocalMessage(m) + LogUtils.logDuration(LOG, "Storing message", start) + + val message = m.message + val h = PrivateMessageHeader( + message.id, message.groupId, + message.timestamp, true, true, false, false, + m.hasText(), m.attachmentHeaders, + m.autoDeleteTimer + ) + _messages.add(0, messageHeaderToItem(h)) + eventBus.broadcast(ConversationMessageToBeSentEvent(h, _contactId.value!!)) + } catch (e: UnexpectedTimerException) { + LogUtils.logException(LOG, Level.WARNING, e) + } catch (e: DbException) { + LogUtils.logException(LOG, Level.WARNING, e) + } + } + + @Throws(DbException::class) + private fun createMessage(text: String): PrivateMessage { + val groupId = messagingManager.getContactGroup(_contactItem.value!!.contact).id + // todo: this API call needs a database transaction context + // val timestamp = conversationManager.getTimestampForOutgoingMessage(_contactId.value!!) + val timestamp = Date().time + try { + return privateMessageFactory.createLegacyPrivateMessage( + groupId, timestamp, text + ) + } catch (e: FormatException) { + throw AssertionError(e) + } + } + + private fun loadMessages() { + try { + val start = LogUtils.now() + val headers = conversationManager.getMessageHeaders(_contactId.value!!) + LogUtils.logDuration(LOG, "Loading messages", start) + // Sort headers by timestamp in *descending* order + val sorted = headers.sortedByDescending { it.timestamp } + _messages.apply { + clear() + addAll( + // todo: use ConversationVisitor to also display Request and Notice Messages + sorted.filterIsInstance<PrivateMessageHeader>().map(::messageHeaderToItem) + ) + } + } catch (e: NoSuchContactException) { + // finishOnUiThread() + } catch (e: DbException) { + LogUtils.logException(LOG, Level.WARNING, e) + } + } + + private fun messageHeaderToItem(h: PrivateMessageHeader): ConversationMessageItem { + // todo: use ConversationVisitor instead and support other MessageHeader + val item = ConversationMessageItem(h) + if (h.hasText()) { + item.text = loadMessageText(h.id) + } + return item + } + + private fun loadMessageText(m: MessageId): String? { + try { + val start = LogUtils.now() + val text = messagingManager.getMessageText(m) + LogUtils.logDuration(LOG, "Loading text", start) + + return text + } catch (e: DbException) { + LogUtils.logException(LOG, Level.WARNING, e) + } + return null + } + + override fun eventOccurred(e: Event?) { + when (e) { + is ContactRemovedEvent -> { + if (e.contactId == _contactId.value) { + LOG.info("Contact removed") + // todo: we probably don't need to react to this here as the ContactsViewModel should already handle it + } + } + is ConversationMessageReceivedEvent<*> -> { + if (e.contactId == _contactId.value) { + LOG.info("Message received, adding") + val h = e.messageHeader + if (h is PrivateMessageHeader) { + // insert at start of list according to descending sort order + _messages.add(0, messageHeaderToItem(h)) + } + } + } + is MessagesSentEvent -> { + if (e.contactId == _contactId.value) { + LOG.info("Messages sent") + markMessages(e.messageIds, sent = true, seen = false) + } + } + is MessagesAckedEvent -> { + if (e.contactId == _contactId.value) { + LOG.info("Messages acked") + markMessages(e.messageIds, sent = true, seen = true) + } + } + is ConversationMessagesDeletedEvent -> { + if (e.contactId == _contactId.value) { + LOG.info("Messages auto-deleted") + val messages = HashSet(e.messageIds) + _messages.removeIf { messages.contains(it.id) } + } + } + is ContactConnectedEvent -> { + if (e.contactId == _contactId.value) { + LOG.info("Contact connected") + _contactItem.value = _contactItem.value!!.updateIsConnected(true) + } + } + is ContactDisconnectedEvent -> { + if (e.contactId == _contactId.value) { + LOG.info("Contact disconnected") + _contactItem.value = _contactItem.value!!.updateIsConnected(false) + } + } + is ClientVersionUpdatedEvent -> { + if (e.contactId == _contactId.value) { + // todo: still not implemented + } + } + } + } + + private fun markMessages( + messageIds: Collection<MessageId>, + sent: Boolean, + seen: Boolean + ) { + val messages = HashSet(messageIds) + _messages.replaceIf({ !it.isIncoming && messages.contains(it.id) }) { + it.mark(sent, seen) + } + } +} diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/PrivateMessageView.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/PrivateMessageView.kt index b9dc7644181c81c97321295ee10b97a739fd32d4..2ce3ccbbde31424da9bec60638c4a965428811bf 100644 --- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/PrivateMessageView.kt +++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/PrivateMessageView.kt @@ -16,6 +16,7 @@ import org.briarproject.briar.desktop.ui.VerticalDivider @Composable fun PrivateMessageView( contactListViewModel: ContactListViewModel, + conversationViewModel: ConversationViewModel, addContactViewModel: AddContactViewModel, introductionViewModel: IntroductionViewModel, ) { @@ -23,9 +24,10 @@ fun PrivateMessageView( ContactList(contactListViewModel, addContactViewModel) VerticalDivider() Column(modifier = Modifier.weight(1f).fillMaxHeight()) { - contactListViewModel.selectedContact.value?.also { selectedContact -> + contactListViewModel.selectedContactId.value?.also { contactId -> Conversation( - selectedContact, + contactId, + conversationViewModel, introductionViewModel ) } ?: UiPlaceholder() diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/SimpleMessage.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/SimpleMessage.kt deleted file mode 100644 index 686f1e53151c5bd51b46633ee8fc5df58e97f524..0000000000000000000000000000000000000000 --- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/SimpleMessage.kt +++ /dev/null @@ -1,10 +0,0 @@ -package org.briarproject.briar.desktop.conversation - -data class SimpleMessage( - val local: Boolean, - val from: String?, - val message: String, - val time: String, - val delivered: Boolean, - // val read: Boolean, -) diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/TextBubble.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/TextBubble.kt index 543f06ad956b7498b400234da970520bfbadeb63..33f8a365d44d1cedd724d926bc9eb30405deebad 100644 --- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/TextBubble.kt +++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/TextBubble.kt @@ -11,53 +11,41 @@ import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.filled.DoneAll import androidx.compose.material.icons.filled.Schedule import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.briarproject.briar.desktop.theme.awayMsgBubble import org.briarproject.briar.desktop.theme.localMsgBubble +import org.briarproject.briar.desktop.utils.TimeUtils @Composable -fun TextBubble(m: SimpleMessage) { - if (m.local) { - TextBubble( - m, - Alignment.End, - MaterialTheme.colors.localMsgBubble, - RoundedCornerShape(topStart = 10.dp, topEnd = 10.dp, bottomStart = 10.dp) - ) - } else { - TextBubble( - m, - Alignment.Start, - MaterialTheme.colors.awayMsgBubble, - RoundedCornerShape(topStart = 10.dp, topEnd = 10.dp, bottomEnd = 10.dp) - ) - } -} +fun TextBubble(m: ConversationMessageItem) { + val alignment = if (m.isIncoming) Alignment.Start else Alignment.End + val color = if (m.isIncoming) MaterialTheme.colors.awayMsgBubble else MaterialTheme.colors.localMsgBubble + val shape = if (m.isIncoming) + RoundedCornerShape(topStart = 10.dp, topEnd = 10.dp, bottomEnd = 10.dp) + else + RoundedCornerShape(topStart = 10.dp, topEnd = 10.dp, bottomStart = 10.dp) -@Composable -fun TextBubble(m: SimpleMessage, alignment: Alignment.Horizontal, color: Color, shape: RoundedCornerShape) { Column(Modifier.fillMaxWidth()) { Column(Modifier.fillMaxWidth(fraction = 0.8f).align(alignment)) { Card(Modifier.align(alignment), backgroundColor = color, shape = shape) { Column( Modifier.padding(8.dp) ) { - Text(m.message, fontSize = 14.sp, modifier = Modifier.align(Alignment.Start)) + Text(m.text!!, fontSize = 14.sp, modifier = Modifier.align(Alignment.Start)) Row(modifier = Modifier.padding(top = 4.dp)) { - Text(m.time, Modifier.padding(end = 4.dp), fontSize = 10.sp) - if (m.delivered) { - val modifier = Modifier.size(12.dp).align(Alignment.CenterVertically) - Icon(Icons.Filled.DoneAll, "sent", modifier) - } else { + Text(TimeUtils.getFormattedTimestamp(m.time), Modifier.padding(end = 4.dp), fontSize = 10.sp) + if (!m.isIncoming) { val modifier = Modifier.size(12.dp).align(Alignment.CenterVertically) - Icon(Icons.Filled.Schedule, "sending", modifier) + val icon = + if (m.isSeen) Icons.Filled.DoneAll else if (m.isSent) Icons.Filled.Done else Icons.Filled.Schedule + Icon(icon, "sent", modifier) } } } diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/TextBubbles.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/TextBubbles.kt deleted file mode 100644 index 8d3f73fc5e310b8149294e089d8152db8e81939b..0000000000000000000000000000000000000000 --- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/TextBubbles.kt +++ /dev/null @@ -1,26 +0,0 @@ -package org.briarproject.briar.desktop.conversation - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.runtime.Composable -import androidx.compose.ui.unit.dp -import org.briarproject.briar.desktop.ui.Loader -import org.briarproject.briar.desktop.ui.UiState - -@Composable -fun TextBubbles(chat: UiState<Chat>) { - when (chat) { - is UiState.Loading -> Loader() - is UiState.Error -> Loader() - is UiState.Success -> - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), - reverseLayout = true, - contentPadding = PaddingValues(8.dp) - ) { - items(chat.data.messages) { m -> TextBubble(m) } - } - } -} diff --git a/src/main/kotlin/org/briarproject/briar/desktop/ui/BriarUi.kt b/src/main/kotlin/org/briarproject/briar/desktop/ui/BriarUi.kt index f621dab9eb5a47577d6700d6010fb7fc320d56d2..d28cf156198dd2b55b346e8a91c35717a50d45dd 100644 --- a/src/main/kotlin/org/briarproject/briar/desktop/ui/BriarUi.kt +++ b/src/main/kotlin/org/briarproject/briar/desktop/ui/BriarUi.kt @@ -17,6 +17,7 @@ import org.briarproject.briar.api.conversation.ConversationManager import org.briarproject.briar.api.messaging.MessagingManager import org.briarproject.briar.desktop.contact.ContactListViewModel import org.briarproject.briar.desktop.contact.add.remote.AddContactViewModel +import org.briarproject.briar.desktop.conversation.ConversationViewModel import org.briarproject.briar.desktop.introduction.IntroductionViewModel import org.briarproject.briar.desktop.login.Login import org.briarproject.briar.desktop.login.LoginViewModel @@ -55,6 +56,7 @@ constructor( private val registrationViewModel: RegistrationViewModel, private val loginViewModel: LoginViewModel, private val contactListViewModel: ContactListViewModel, + private val conversationViewModel: ConversationViewModel, private val addContactViewModel: AddContactViewModel, private val introductionViewModel: IntroductionViewModel, private val accountManager: AccountManager, @@ -120,6 +122,7 @@ constructor( ) { MainScreen( contactListViewModel, + conversationViewModel, addContactViewModel, introductionViewModel, isDark, diff --git a/src/main/kotlin/org/briarproject/briar/desktop/ui/Loader.kt b/src/main/kotlin/org/briarproject/briar/desktop/ui/Loader.kt index 0cfb9434ca0dd1ff4691d1684008cdedde84ff40..9bd57e08271ffabdaaeb3ca43d8642cf90d4fc4d 100644 --- a/src/main/kotlin/org/briarproject/briar/desktop/ui/Loader.kt +++ b/src/main/kotlin/org/briarproject/briar/desktop/ui/Loader.kt @@ -1,9 +1,11 @@ package org.briarproject.briar.desktop.ui +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -13,7 +15,7 @@ import androidx.compose.ui.unit.dp fun Loader() { Box( contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxWidth().padding(20.dp) + modifier = Modifier.fillMaxSize().background(MaterialTheme.colors.background).padding(20.dp) ) { CircularProgressIndicator() } diff --git a/src/main/kotlin/org/briarproject/briar/desktop/ui/MainScreen.kt b/src/main/kotlin/org/briarproject/briar/desktop/ui/MainScreen.kt index 96622e0c932e6318b7dec7a94aa5756cbff42539..69a036fd273eeddaae837ee47bd443d2cfe75ee2 100644 --- a/src/main/kotlin/org/briarproject/briar/desktop/ui/MainScreen.kt +++ b/src/main/kotlin/org/briarproject/briar/desktop/ui/MainScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import org.briarproject.briar.desktop.contact.ContactListViewModel import org.briarproject.briar.desktop.contact.add.remote.AddContactViewModel +import org.briarproject.briar.desktop.conversation.ConversationViewModel import org.briarproject.briar.desktop.conversation.PrivateMessageView import org.briarproject.briar.desktop.introduction.IntroductionViewModel import org.briarproject.briar.desktop.navigation.BriarSidebar @@ -19,6 +20,7 @@ import org.briarproject.briar.desktop.settings.PlaceHolderSettingsView @Composable fun MainScreen( contactListViewModel: ContactListViewModel, + conversationViewModel: ConversationViewModel, addContactViewModel: AddContactViewModel, introductionViewModel: IntroductionViewModel, isDark: Boolean, @@ -31,7 +33,12 @@ fun MainScreen( BriarSidebar(uiMode, setUiMode) VerticalDivider() when (uiMode) { - UiMode.CONTACTS -> PrivateMessageView(contactListViewModel, addContactViewModel, introductionViewModel) + UiMode.CONTACTS -> PrivateMessageView( + contactListViewModel, + conversationViewModel, + addContactViewModel, + introductionViewModel + ) UiMode.SETTINGS -> PlaceHolderSettingsView(isDark, setDark) else -> UiPlaceholder() } diff --git a/src/main/kotlin/org/briarproject/briar/desktop/utils/ListUtils.kt b/src/main/kotlin/org/briarproject/briar/desktop/utils/ListUtils.kt index 3adad7c3f2e0d7746102862e9cefa120c5e2740c..b2221c8a5275f3e4e375fb438c4677fc04111744 100644 --- a/src/main/kotlin/org/briarproject/briar/desktop/utils/ListUtils.kt +++ b/src/main/kotlin/org/briarproject/briar/desktop/utils/ListUtils.kt @@ -1,5 +1,15 @@ package org.briarproject.briar.desktop.utils +fun <T> MutableList<T>.replaceIf(predicate: (T) -> Boolean, transformation: (T) -> T) { + val li = listIterator() + while (li.hasNext()) { + val n = li.next() + if (predicate(n)) { + li.set(transformation(n)) + } + } +} + fun <T> MutableList<T>.replaceFirst(predicate: (T) -> Boolean, transformation: (T) -> T) { val li = listIterator() while (li.hasNext()) {