diff --git a/src/main/kotlin/org/briarproject/briar/desktop/privategroups/PrivateGroupCard.kt b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/PrivateGroupCard.kt new file mode 100644 index 0000000000000000000000000000000000000000..b6f75800bcd7c2f67ef73aad116c2c4d6f41ed99 --- /dev/null +++ b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/PrivateGroupCard.kt @@ -0,0 +1,81 @@ +package org.briarproject.briar.desktop.privategroups + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +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.briar.desktop.contact.ProfileCircle +import org.briarproject.briar.desktop.theme.outline +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.ui.HorizontalDivider +import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n +import org.briarproject.briar.desktop.utils.TimeUtils.getFormattedTimestamp + +@Composable +fun PrivateGroupCard( + privateGroupItem: PrivateGroupItem, + onSel: () -> Unit, + selected: Boolean, +) { + val bgColor = if (selected) MaterialTheme.colors.selectedCard else MaterialTheme.colors.surfaceVariant + val outlineColor = MaterialTheme.colors.outline + val briarPrimary = MaterialTheme.colors.primary + val briarSecondary = MaterialTheme.colors.secondary + val briarSurfaceVar = MaterialTheme.colors.surfaceVariant + + Card( + modifier = Modifier.fillMaxWidth().height(HEADER_SIZE).clickable(onClick = onSel), + shape = RoundedCornerShape(0.dp), + backgroundColor = bgColor, + contentColor = MaterialTheme.colors.onSurface + ) { + Row(horizontalArrangement = Arrangement.SpaceBetween) { + Row(modifier = Modifier.align(Alignment.CenterVertically).padding(horizontal = 16.dp)) { + // TODO Pull profile pictures + ProfileCircle(36.dp, privateGroupItem.privateGroup.id.bytes) + // Draw notification badges + if (privateGroupItem.unread > 0) { + Canvas( + modifier = Modifier.align(Alignment.CenterVertically), + onDraw = { + val size = 10.dp.toPx() + withTransform({ translate(left = -6f, top = -12f) }) { + drawCircle(color = outlineColor, radius = (size + 2.dp.toPx()) / 2f) + drawCircle(color = briarPrimary, radius = size / 2f) + } + } + ) + } + Column(modifier = Modifier.align(Alignment.CenterVertically).padding(start = 12.dp)) { + Text( + privateGroupItem.privateGroup.name, + fontSize = 14.sp, + modifier = Modifier.align(Alignment.Start).padding(bottom = 2.dp) + ) + Text( + if (privateGroupItem.isEmpty) i18n("contacts.card.nothing") else getFormattedTimestamp(privateGroupItem.timestamp), + fontSize = 10.sp, + modifier = Modifier.align(Alignment.Start) + ) + } + } + } + } + HorizontalDivider() +} diff --git a/src/main/kotlin/org/briarproject/briar/desktop/privategroups/PrivateGroupInfoDrawer.kt b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/PrivateGroupInfoDrawer.kt new file mode 100644 index 0000000000000000000000000000000000000000..ae32c89a81a0a8994801582ac9c6256675916eab --- /dev/null +++ b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/PrivateGroupInfoDrawer.kt @@ -0,0 +1,25 @@ +package org.briarproject.briar.desktop.privategroups + +import androidx.compose.runtime.Composable +import org.briarproject.briar.api.privategroup.PrivateGroup +import org.briarproject.briar.desktop.contact.ContactInfoDrawerState + +// Right drawer state +enum class PrivateGroupInfoDrawerState { + MakeIntro, + ConnectBT, + ConnectRD +} + +@Composable +fun ContactInfoDrawer( + privateGroup: PrivateGroup, + setInfoDrawer: (Boolean) -> Unit, + drawerState: ContactInfoDrawerState +) { + /* TODO + when (drawerState) { + MakeIntro -> ContactDrawerMakeIntro(privateGroup, setInfoDrawer) + } + */ +} diff --git a/src/main/kotlin/org/briarproject/briar/desktop/privategroups/PrivateGroupItem.kt b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/PrivateGroupItem.kt new file mode 100644 index 0000000000000000000000000000000000000000..b6677fc3e7b75a4553d94e13a3909406b3fc0bb2 --- /dev/null +++ b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/PrivateGroupItem.kt @@ -0,0 +1,38 @@ +package org.briarproject.briar.desktop.privategroups + +import org.briarproject.briar.api.client.MessageTracker +import org.briarproject.briar.api.privategroup.GroupMessageHeader +import org.briarproject.briar.api.privategroup.PrivateGroup +import kotlin.math.max + +data class PrivateGroupItem( + val privateGroup: PrivateGroup, + val isEmpty: Boolean, + val unread: Int, + val timestamp: Long +) { + + constructor(privateGroup: PrivateGroup, groupCount: MessageTracker.GroupCount) : + this( + privateGroup, + isEmpty = groupCount.msgCount == 0, + unread = groupCount.unreadCount, + timestamp = groupCount.latestMsgTime + ) + + fun updateFromMessageHeader(h: GroupMessageHeader): PrivateGroupItem { + return copy( + isEmpty = false, + unread = if (h.isRead) unread else unread + 1, + timestamp = max(h.timestamp, timestamp) + ) + } + + fun updateName(name: String): PrivateGroupItem { + return copy(privateGroup = privateGroup.updateName(name)) + } + + private fun PrivateGroup.updateName(name: String): PrivateGroup { + return PrivateGroup(group, name, creator, salt) + } +} diff --git a/src/main/kotlin/org/briarproject/briar/desktop/privategroups/PrivateGroupList.kt b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/PrivateGroupList.kt new file mode 100644 index 0000000000000000000000000000000000000000..10304c6115c8fbb1d8dc44faf452459baa95ffc5 --- /dev/null +++ b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/PrivateGroupList.kt @@ -0,0 +1,62 @@ +package org.briarproject.briar.desktop.privategroups + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +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.unit.dp +import org.briarproject.bramble.api.sync.GroupId +import org.briarproject.briar.desktop.contact.SearchTextField +import org.briarproject.briar.desktop.contact.add.remote.AddContactDialog +import org.briarproject.briar.desktop.theme.surfaceVariant +import org.briarproject.briar.desktop.ui.Constants.HEADER_SIZE +import org.briarproject.briar.desktop.ui.Constants.PRIVATE_GROUP_LIST_WIDTH + +@Composable +fun PrivateGroupList( + privateGroupList: List<PrivateGroupItem>, + isSelected: (GroupId) -> Boolean, + selectPrivateGroup: (GroupId) -> Unit, + filterBy: String, + setFilterBy: (String) -> Unit, +) { + var isCreatePrivateGroupDialogVisible by remember { mutableStateOf(false) } + if (isCreatePrivateGroupDialogVisible) AddContactDialog(onClose = { isCreatePrivateGroupDialogVisible = false }) + Scaffold( + modifier = Modifier.fillMaxHeight().width(PRIVATE_GROUP_LIST_WIDTH), + backgroundColor = MaterialTheme.colors.surfaceVariant, + topBar = { + Column( + modifier = Modifier.fillMaxWidth().height(HEADER_SIZE + 1.dp), + ) { + SearchTextField( + filterBy, + onValueChange = setFilterBy, + onContactAdd = { isCreatePrivateGroupDialogVisible = true } + ) + } + }, + content = { + LazyColumn { + items(privateGroupList) { privateGroupItem -> + PrivateGroupCard( + privateGroupItem, + { selectPrivateGroup(privateGroupItem.privateGroup.id) }, + isSelected(privateGroupItem.privateGroup.id) + ) + } + } + }, + ) +} diff --git a/src/main/kotlin/org/briarproject/briar/desktop/privategroups/PrivateGroupListViewModel.kt b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/PrivateGroupListViewModel.kt new file mode 100644 index 0000000000000000000000000000000000000000..d3f27d8443d961fa24a61de0c8fece1bb835d71c --- /dev/null +++ b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/PrivateGroupListViewModel.kt @@ -0,0 +1,131 @@ +package org.briarproject.briar.desktop.privategroups + +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import org.briarproject.bramble.api.connection.ConnectionRegistry +import org.briarproject.bramble.api.contact.ContactId +import org.briarproject.bramble.api.event.Event +import org.briarproject.bramble.api.event.EventBus +import org.briarproject.bramble.api.sync.GroupId +import org.briarproject.briar.api.conversation.ConversationManager +import org.briarproject.briar.api.privategroup.PrivateGroup +import org.briarproject.briar.api.privategroup.PrivateGroupManager +import org.briarproject.briar.desktop.utils.removeFirst +import org.briarproject.briar.desktop.utils.replaceFirst +import org.briarproject.briar.desktop.viewmodel.BriarEventListenerViewModel +import java.util.logging.Logger +import javax.inject.Inject + +class PrivateGroupListViewModel +@Inject +constructor( + val privateGroupManager: PrivateGroupManager, + val conversationManager: ConversationManager, + val connectionRegistry: ConnectionRegistry, + eventBus: EventBus, +) : BriarEventListenerViewModel(eventBus) { + + companion object { + private val LOG = Logger.getLogger(PrivateGroupListViewModel::class.java.name) + } + + private val _fullContactList = mutableListOf<PrivateGroupItem>() + private val _filteredContactList = mutableStateListOf<PrivateGroupItem>() + + val privateGroupList: List<PrivateGroupItem> = _filteredContactList + + private fun loadPrivateGroups() { + _fullContactList.apply { + clear() + addAll( + privateGroupManager.privateGroups.map { privateGroup -> + PrivateGroupItem( + privateGroup, + privateGroupManager.getGroupCount(privateGroup.id), + ) + } + ) + } + updateFilteredList() + } + + private fun updateItem(contactId: ContactId, update: (PrivateGroupItem) -> PrivateGroupItem) { + _fullContactList.replaceFirst({ it.privateGroup.id == contactId }, update) + updateFilteredList() + } + + private fun removeItem(groupId: GroupId) { + _fullContactList.removeFirst { it.privateGroup.id == groupId } + updateFilteredList() + } + + override fun onInit() { + super.onInit() + loadPrivateGroups() + } + + private val _filterBy = mutableStateOf("") + private val _selectedContactId = mutableStateOf<GroupId?>(null) + + val filterBy: State<String> = _filterBy + val selectedPrivateGroupId: State<GroupId?> = _selectedContactId + + fun selectPrivateGroup(privateGroupId: GroupId) { + _selectedContactId.value = privateGroupId + } + + fun isSelected(privateGroupId: GroupId) = _selectedContactId.value == privateGroupId + + private fun filterContact(privateGroup: PrivateGroup) = + // todo: also filter on alias + privateGroup.name.contains(_filterBy.value, ignoreCase = true) + + fun setFilterBy(filter: String) { + _filterBy.value = filter + updateFilteredList() + } + + // todo: when migrated to StateFlow, this could be done implicitly instead + fun updateFilteredList() { + _filteredContactList.apply { + clear() + addAll(_fullContactList.filter { filterContact(it.privateGroup) }.sortedByDescending { it.timestamp }) + } + + // reset selected contact to null if not available after filtering + val id = _selectedContactId.value + if (id != null && !privateGroupList.map { it.privateGroup.id }.contains(id)) { + _selectedContactId.value = null + } + } + + override fun eventOccurred(e: Event?) { + /* + when (e) { + is ContactAddedEvent -> { + LOG.info("Contact added, reloading") + loadPrivateGroups() + } + is ContactRemovedEvent -> { + LOG.info("Contact removed, removing item") + removeItem(e.contactId) + } + } + when (e) { + is ConversationMessageReceivedEvent<*> -> { + 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/privategroups/PrivateGroupScreen.kt b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/PrivateGroupScreen.kt new file mode 100644 index 0000000000000000000000000000000000000000..2046b4689db0c5ae9fc31d991d45aab6af81972b --- /dev/null +++ b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/PrivateGroupScreen.kt @@ -0,0 +1,35 @@ +package org.briarproject.briar.desktop.privategroups + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.briarproject.briar.desktop.ui.UiPlaceholder +import org.briarproject.briar.desktop.ui.VerticalDivider +import org.briarproject.briar.desktop.viewmodel.viewModel + +@Composable +fun PrivateGroupScreen( + viewModel: PrivateGroupListViewModel = viewModel(), +) { + Row(modifier = Modifier.fillMaxWidth()) { + PrivateGroupList( + viewModel.privateGroupList, + viewModel::isSelected, + viewModel::selectPrivateGroup, + viewModel.filterBy.value, + viewModel::setFilterBy + ) + VerticalDivider() + Column(modifier = Modifier.weight(1f).fillMaxHeight()) { + val id = viewModel.selectedPrivateGroupId.value + if (id != null) { + ThreadedConversationScreen(id) + } else { + UiPlaceholder() + } + } + } +} diff --git a/src/main/kotlin/org/briarproject/briar/desktop/privategroups/ThreadedConversationHeader.kt b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/ThreadedConversationHeader.kt new file mode 100644 index 0000000000000000000000000000000000000000..5146a684aa0f5fcbc86429e9a5526c39ea383d5c --- /dev/null +++ b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/ThreadedConversationHeader.kt @@ -0,0 +1,55 @@ +package org.briarproject.briar.desktop.privategroups + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.ContactDropDown +import org.briarproject.briar.desktop.contact.ProfileCircle +import org.briarproject.briar.desktop.theme.outline +import org.briarproject.briar.desktop.ui.Constants.HEADER_SIZE +import org.briarproject.briar.desktop.ui.HorizontalDivider +import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n + +@Composable +fun ThreadedConversationHeader( + privateGroupItem: PrivateGroupItem, + onMakeIntroduction: () -> Unit, +) { + val (isExpanded, setExpanded) = remember { mutableStateOf(false) } + val outlineColor = MaterialTheme.colors.outline + + Box(modifier = Modifier.fillMaxWidth().height(HEADER_SIZE + 1.dp)) { + Row(modifier = Modifier.align(Alignment.Center)) { + ProfileCircle(36.dp, privateGroupItem.privateGroup.id.bytes) + Text( + privateGroupItem.privateGroup.name, + modifier = Modifier.align(Alignment.CenterVertically).padding(start = 12.dp), + fontSize = 20.sp + ) + } + IconButton( + onClick = { setExpanded(!isExpanded) }, + modifier = Modifier.align(Alignment.CenterEnd).padding(end = 16.dp) + ) { + Icon(Icons.Filled.MoreVert, i18n("access.contact.menu"), modifier = Modifier.size(24.dp)) + ContactDropDown(isExpanded, setExpanded, onMakeIntroduction) + } + HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter)) + } +} diff --git a/src/main/kotlin/org/briarproject/briar/desktop/privategroups/ThreadedConversationItem.kt b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/ThreadedConversationItem.kt new file mode 100644 index 0000000000000000000000000000000000000000..4ba592fd8867e9ebb646d30e5783c507d944fd73 --- /dev/null +++ b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/ThreadedConversationItem.kt @@ -0,0 +1,31 @@ +package org.briarproject.briar.desktop.privategroups + +import org.briarproject.bramble.api.sync.GroupId +import org.briarproject.bramble.api.sync.MessageId + +sealed class ThreadedConversationItem { + 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): ThreadedConversationItem + + abstract fun markRead(): ThreadedConversationItem +} diff --git a/src/main/kotlin/org/briarproject/briar/desktop/privategroups/ThreadedConversationMessageItem.kt b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/ThreadedConversationMessageItem.kt new file mode 100644 index 0000000000000000000000000000000000000000..f9b64bd3a4061aeeaef8912f117be08bdcb3acdd --- /dev/null +++ b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/ThreadedConversationMessageItem.kt @@ -0,0 +1,41 @@ +package org.briarproject.briar.desktop.privategroups + +import org.briarproject.bramble.api.sync.GroupId +import org.briarproject.bramble.api.sync.MessageId +import org.briarproject.briar.api.conversation.ConversationMessageHeader + +data class ThreadedConversationMessageItem( + 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> +) : ThreadedConversationItem() { + + 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): ThreadedConversationItem { + return copy(isSent = sent, isSeen = seen) + } + + override fun markRead(): ThreadedConversationItem { + return copy(isRead = true) + } +} diff --git a/src/main/kotlin/org/briarproject/briar/desktop/privategroups/ThreadedConversationScreen.kt b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/ThreadedConversationScreen.kt new file mode 100644 index 0000000000000000000000000000000000000000..db0b50ffc31f9f53eee2cc97b7d10beccdd50bc4 --- /dev/null +++ b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/ThreadedConversationScreen.kt @@ -0,0 +1,116 @@ +package org.briarproject.briar.desktop.privategroups + +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.sync.GroupId +import org.briarproject.briar.desktop.contact.ContactInfoDrawer +import org.briarproject.briar.desktop.contact.ContactInfoDrawerState +import org.briarproject.briar.desktop.conversation.ConversationInput +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 +import org.briarproject.briar.desktop.viewmodel.viewModel + +@Composable +fun ThreadedConversationScreen( + groupId: GroupId, + viewModel: ThreadedConversationViewModel = viewModel(), +) { + LaunchedEffect(groupId) { + viewModel.setGroupId(groupId) + } + + val contactItem = viewModel.contactItem.value + + if (contactItem == null) { + Loader() + return + } + + val (infoDrawer, setInfoDrawer) = remember { mutableStateOf(false) } + val (contactDrawerState, setDrawerState) = remember { mutableStateOf(ContactInfoDrawerState.MakeIntro) } + BoxWithConstraints(Modifier.fillMaxSize()) { + val animatedInfoDrawerOffsetX by animateDpAsState(if (infoDrawer) (-275).dp else 0.dp) + Scaffold( + topBar = { + ThreadedConversationHeader( + contactItem, + onMakeIntroduction = { + setInfoDrawer(true) + } + ) + }, + content = { padding -> + 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(viewModel.messages) { m -> + if (m is ThreadedConversationMessageItem) + ThreadedText(m) + } + } + }, + bottomBar = { + ConversationInput( + viewModel.newMessage.value, + viewModel::setNewMessage, + viewModel::sendMessage + ) + }, + ) + if (infoDrawer) { + // TODO Find non-hacky way of setting scrim on entire app + Box( + Modifier.offset(-(CONTACTLIST_WIDTH + SIDEBAR_WIDTH)) + .requiredSize(maxWidth + CONTACTLIST_WIDTH + SIDEBAR_WIDTH, maxHeight) + .background(Color(0, 0, 0, 100)) + .clickable( + // prevent visual indication + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { setInfoDrawer(false) } + ) + Column( + modifier = Modifier.fillMaxHeight().width(CONTACTLIST_WIDTH) + .offset(maxWidth + animatedInfoDrawerOffsetX) + .background( + MaterialTheme.colors.surfaceVariant, + RoundedCornerShape(topStart = 10.dp, bottomStart = 10.dp) + ) + ) { + ContactInfoDrawer(contactItem.privateGroup, setInfoDrawer, contactDrawerState) + } + } + } +} diff --git a/src/main/kotlin/org/briarproject/briar/desktop/privategroups/ThreadedConversationViewModel.kt b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/ThreadedConversationViewModel.kt new file mode 100644 index 0000000000000000000000000000000000000000..05bc249a66785d6448ac61ebe24c8d442047fee6 --- /dev/null +++ b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/ThreadedConversationViewModel.kt @@ -0,0 +1,232 @@ +package org.briarproject.briar.desktop.privategroups + +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.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.sync.GroupId +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.api.privategroup.PrivateGroupManager +import org.briarproject.briar.desktop.utils.replaceIf +import org.briarproject.briar.desktop.viewmodel.BriarEventListenerViewModel +import java.util.Date +import java.util.logging.Level +import java.util.logging.Logger +import javax.inject.Inject + +class ThreadedConversationViewModel +@Inject +constructor( + private val privateGroupManager: PrivateGroupManager, + private val conversationManager: ConversationManager, + private val messagingManager: MessagingManager, + private val privateMessageFactory: PrivateMessageFactory, + private val eventBus: EventBus, +) : BriarEventListenerViewModel(eventBus) { + + companion object { + private val LOG = Logger.getLogger(ThreadedConversationViewModel::class.java.name) + } + + private val _privateGroupId = mutableStateOf<GroupId?>(null) + private val _privateGroupItem = mutableStateOf<PrivateGroupItem?>(null) + private val _messages = mutableStateListOf<ThreadedConversationItem>() + + private val _newMessage = mutableStateOf("") + + val contactItem: State<PrivateGroupItem?> = _privateGroupItem + val messages: List<ThreadedConversationItem> = _messages + + val newMessage: State<String> = _newMessage + + fun setGroupId(id: GroupId) { + if (_privateGroupId.value == id) + return + + _privateGroupId.value = id + _privateGroupItem.value = PrivateGroupItem( + privateGroupManager.getPrivateGroup(id), + privateGroupManager.getGroupCount(id), + ) + loadMessages() + setNewMessage("") + } + + fun setNewMessage(msg: String) { + _newMessage.value = msg + } + + fun sendMessage() { + try { + val text = _newMessage.value + _newMessage.value = "" + + // don't send empty or blank messages + if (text.isBlank()) return + + 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 = _privateGroupItem.value!!.privateGroup.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 = privateGroupManager.getHeaders(_privateGroupId.value!!) + LogUtils.logDuration(LOG, "Loading message headers", start) + // Sort headers by timestamp in *descending* order + val sorted = headers.sortedByDescending { it.timestamp } + _messages.apply { + clear() + val start = LogUtils.now() + addAll( + // todo: use ConversationVisitor to also display Request and Notice Messages + sorted.filterIsInstance<PrivateMessageHeader>().map(::messageHeaderToItem) + ) + LogUtils.logDuration(LOG, "Loading messages", start) + } + } catch (e: NoSuchContactException) { + LogUtils.logException(LOG, Level.WARNING, e) + } catch (e: DbException) { + LogUtils.logException(LOG, Level.WARNING, e) + } + } + + private fun messageHeaderToItem(h: PrivateMessageHeader): ThreadedConversationMessageItem { + // todo: use ConversationVisitor instead and support other MessageHeader + val item = ThreadedConversationMessageItem(h) + if (h.hasText()) { + item.text = loadMessageText(h.id) + } else { + LOG.warning { "private message without text" } + } + return item + } + + private fun loadMessageText(m: MessageId): String? { + try { + return messagingManager.getMessageText(m) + } catch (e: DbException) { + LogUtils.logException(LOG, Level.WARNING, e) + } + return null + } + + override fun eventOccurred(e: Event?) { + when (e) { + is ContactRemovedEvent -> { + if (e.contactId == _privateGroupId.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 == _privateGroupId.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 == _privateGroupId.value) { + LOG.info("Messages sent") + markMessages(e.messageIds, sent = true, seen = false) + } + } + is MessagesAckedEvent -> { + if (e.contactId == _privateGroupId.value) { + LOG.info("Messages acked") + markMessages(e.messageIds, sent = true, seen = true) + } + } + is ConversationMessagesDeletedEvent -> { + if (e.contactId == _privateGroupId.value) { + LOG.info("Messages auto-deleted") + val messages = HashSet(e.messageIds) + _messages.removeIf { messages.contains(it.id) } + } + } + /* + is ContactConnectedEvent -> { + if (e.contactId == _privateGroupId.value) { + LOG.info("Contact connected") + _privateGroupItem.value = _privateGroupItem.value!!.updateIsConnected(true) + } + } + is ContactDisconnectedEvent -> { + if (e.contactId == _privateGroupId.value) { + LOG.info("Contact disconnected") + _privateGroupItem.value = _privateGroupItem.value!!.updateIsConnected(false) + } + } + */ + is ClientVersionUpdatedEvent -> { + if (e.contactId == _privateGroupId.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/privategroups/ThreadedText.kt b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/ThreadedText.kt new file mode 100644 index 0000000000000000000000000000000000000000..4b5eabad729c1f2dcaabae2ab265ee8870a5dc94 --- /dev/null +++ b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/ThreadedText.kt @@ -0,0 +1,58 @@ +package org.briarproject.briar.desktop.privategroups + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.Card +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.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.InternationalizationUtils.i18n +import org.briarproject.briar.desktop.utils.TimeUtils + +@Composable +fun ThreadedText(m: ThreadedConversationMessageItem) { + 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) + + 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.text!!, fontSize = 14.sp, modifier = Modifier.align(Alignment.Start)) + Row(modifier = Modifier.padding(top = 4.dp)) { + 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) + val icon = + if (m.isSeen) Icons.Filled.DoneAll // acknowledged + else if (m.isSent) Icons.Filled.Done // sent + else Icons.Filled.Schedule // waiting + Icon(icon, i18n("access.message.sent"), modifier) + } + } + } + } + } + } +} 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 dc533cbe859a56cfdc0459a0bc26b28e8dde0f3c..78598663ed696905cf5a8bb5d0987b0818a27b88 100644 --- a/src/main/kotlin/org/briarproject/briar/desktop/ui/Constants.kt +++ b/src/main/kotlin/org/briarproject/briar/desktop/ui/Constants.kt @@ -6,4 +6,5 @@ object Constants { val HEADER_SIZE = 56.dp val CONTACTLIST_WIDTH = 275.dp + val PRIVATE_GROUP_LIST_WIDTH = CONTACTLIST_WIDTH } 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 7196cc58f1da1fb995a1da8b5b01d71d9dd8bbc4..d1334ba25660a1c6733ab93100e228c7db5f4166 100644 --- a/src/main/kotlin/org/briarproject/briar/desktop/ui/MainScreen.kt +++ b/src/main/kotlin/org/briarproject/briar/desktop/ui/MainScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable import org.briarproject.briar.desktop.conversation.PrivateMessageScreen import org.briarproject.briar.desktop.navigation.BriarSidebar import org.briarproject.briar.desktop.navigation.SidebarViewModel +import org.briarproject.briar.desktop.privategroups.PrivateGroupScreen import org.briarproject.briar.desktop.settings.PlaceHolderSettingsView import org.briarproject.briar.desktop.viewmodel.viewModel @@ -28,6 +29,7 @@ fun MainScreen( VerticalDivider() when (viewModel.uiMode.value) { UiMode.CONTACTS -> PrivateMessageScreen() + UiMode.GROUPS -> PrivateGroupScreen() UiMode.SETTINGS -> PlaceHolderSettingsView(isDark, setDark) else -> UiPlaceholder() } diff --git a/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/ViewModelModule.kt b/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/ViewModelModule.kt index a1edb2acb4a579258a2691fd054505ebac214ab4..a90748a289bfd34c97f9c22488e34cef6566deaf 100644 --- a/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/ViewModelModule.kt +++ b/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/ViewModelModule.kt @@ -11,6 +11,8 @@ import org.briarproject.briar.desktop.introduction.IntroductionViewModel import org.briarproject.briar.desktop.login.LoginViewModel import org.briarproject.briar.desktop.login.RegistrationViewModel import org.briarproject.briar.desktop.navigation.SidebarViewModel +import org.briarproject.briar.desktop.privategroups.PrivateGroupListViewModel +import org.briarproject.briar.desktop.privategroups.ThreadedConversationViewModel import kotlin.reflect.KClass @Module @@ -52,4 +54,14 @@ abstract class ViewModelModule { @IntoMap @ViewModelKey(IntroductionViewModel::class) abstract fun bindIntroductionViewModel(introductionViewModel: IntroductionViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(PrivateGroupListViewModel::class) + abstract fun bindPrivateGroupListViewModel(privateGroupListViewModel: PrivateGroupListViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(ThreadedConversationViewModel::class) + abstract fun bindThreadedConversationViewModel(threadedConversationViewModel: ThreadedConversationViewModel): ViewModel }