diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactItemViewSmall.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactItemViewSmall.kt index 3e252154f34a5da44d4a17584420ab3e318f8ae5..3c782b0acafe89c4fee9dd65a5167ebf7ef7c313 100644 --- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactItemViewSmall.kt +++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactItemViewSmall.kt @@ -35,6 +35,7 @@ import org.briarproject.bramble.api.contact.ContactId import org.briarproject.bramble.api.identity.AuthorId import org.briarproject.briar.api.identity.AuthorInfo import org.briarproject.briar.api.identity.AuthorInfo.Status +import org.briarproject.briar.desktop.privategroup.sharing.GroupMemberItem import org.briarproject.briar.desktop.ui.TrustIndicatorShort import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18nF @@ -83,6 +84,18 @@ fun ContactItemViewSmall( modifier = modifier, ) +@Composable +fun ContactItemViewSmall( + groupMemberItem: GroupMemberItem, + modifier: Modifier = Modifier, +) = ContactItemViewSmall( + displayName = groupMemberItem.displayName, + authorId = groupMemberItem.authorId, + authorInfo = groupMemberItem.authorInfo, + isConnected = groupMemberItem.isConnected, + modifier = modifier, +) + @Composable fun ContactItemViewSmall( displayName: String, diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationViewModel.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationViewModel.kt index 607730b4bbb52983f5b5afc7db6fb6ac7682a96a..1b03ee90375d6bf56ae773c31092ccf6f028d567 100644 --- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationViewModel.kt +++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationViewModel.kt @@ -58,11 +58,14 @@ 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.invitation.GroupInvitationManager import org.briarproject.briar.desktop.DesktopFeatureFlags import org.briarproject.briar.desktop.attachment.media.ImageCompressor import org.briarproject.briar.desktop.contact.ContactItem import org.briarproject.briar.desktop.contact.loadContactItem +import org.briarproject.briar.desktop.conversation.ConversationRequestItem.RequestType.BLOG import org.briarproject.briar.desktop.conversation.ConversationRequestItem.RequestType.FORUM +import org.briarproject.briar.desktop.conversation.ConversationRequestItem.RequestType.GROUP import org.briarproject.briar.desktop.conversation.ConversationRequestItem.RequestType.INTRODUCTION import org.briarproject.briar.desktop.forum.sharing.ForumInvitationSentEvent import org.briarproject.briar.desktop.threading.BriarExecutors @@ -92,6 +95,7 @@ constructor( private val conversationManager: ConversationManager, private val introductionManager: IntroductionManager, private val forumSharingManager: ForumSharingManager, + private val groupInvitationManager: GroupInvitationManager, private val messagingManager: MessagingManager, private val privateMessageFactory: PrivateMessageFactory, briarExecutors: BriarExecutors, @@ -443,8 +447,21 @@ constructor( } } - else -> - LOG.e { "Only introduction and forum requests are supported for the time being." } + GROUP -> { + if (desktopFeatureFlags.shouldEnablePrivateGroups()) { + groupInvitationManager.respondToInvitation( + /* txn = */ txn, + /* c = */ _contactId.value!!, + /* s = */ item.sessionId, + /* accept = */ accept + ) + } else { + LOG.e { "Private group requests are not supported for this build." } + } + } + + BLOG -> + LOG.e { "Blogs are not supported for the time being." } } // reload all messages to also show request response message // todo: might be better to have an event to react to, also for (other) outgoing messages diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forum/sharing/ForumSharingViewModel.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forum/sharing/ForumSharingViewModel.kt index 98683c9421de7a2bcd53c6a812831b43010936d9..ee1de6710135c9c84189ab69fe655d8b534c4229 100644 --- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forum/sharing/ForumSharingViewModel.kt +++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forum/sharing/ForumSharingViewModel.kt @@ -31,6 +31,7 @@ import org.briarproject.bramble.api.event.EventBus import org.briarproject.bramble.api.lifecycle.LifecycleManager import org.briarproject.bramble.api.plugin.event.ContactConnectedEvent import org.briarproject.bramble.api.plugin.event.ContactDisconnectedEvent +import org.briarproject.bramble.api.sync.GroupId import org.briarproject.briar.api.conversation.ConversationManager import org.briarproject.briar.api.forum.ForumSharingManager import org.briarproject.briar.api.forum.event.ForumInvitationResponseReceivedEvent @@ -110,9 +111,12 @@ class ForumSharingViewModel @Inject constructor( } override fun reload() { - super.reload() _shareableSelected.value = emptySet() _sharingMessage.value = "" + val groupId = _groupId ?: return + runOnDbThreadWithTransaction(true) { txn -> + loadSharingStatus(txn, groupId) + } } @UiExecutor @@ -176,13 +180,12 @@ class ForumSharingViewModel @Inject constructor( } } - override fun loadSharingStatus(txn: Transaction) { - val groupId = _groupId ?: return + fun loadSharingStatus(txn: Transaction, groupId: GroupId) { val map = contactManager.getContacts(txn).associate { contact -> contact.id to forumSharingManager.getSharingStatus(txn, groupId, contact) } + val sharing = map.filterValues { it == SHARING }.keys txn.attach { - val sharing = map.filterValues { it == SHARING }.keys val online = sharing.fold(0) { acc, it -> if (connectionRegistry.isConnected(it)) acc + 1 else acc } _sharingStatus.value = map diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/privategroup/sharing/GroupMemberItem.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/privategroup/sharing/GroupMemberItem.kt new file mode 100644 index 0000000000000000000000000000000000000000..f990f1786174197adbde52b66a11b51297f6b2fd --- /dev/null +++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/privategroup/sharing/GroupMemberItem.kt @@ -0,0 +1,62 @@ +/* + * Briar Desktop + * Copyright (C) 2021-2023 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.privategroup.sharing + +import org.briarproject.bramble.api.connection.ConnectionRegistry +import org.briarproject.bramble.api.contact.ContactId +import org.briarproject.bramble.api.identity.AuthorId +import org.briarproject.briar.api.identity.AuthorInfo +import org.briarproject.briar.api.privategroup.GroupMember +import org.briarproject.briar.desktop.utils.UiUtils.getContactDisplayName + +data class GroupMemberItem( + val authorId: AuthorId, + val authorInfo: AuthorInfo, + private val name: String, + val isCreator: Boolean, + val contactId: ContactId?, + val isConnected: Boolean?, +) { + val displayName = getContactDisplayName(name, authorInfo.alias) + val isVisibleContact = contactId != null + + constructor( + groupMember: GroupMember, + isConnected: Boolean?, + ) : this( + authorId = groupMember.author.id, + authorInfo = groupMember.authorInfo, + name = groupMember.author.name, + isCreator = groupMember.isCreator, + contactId = groupMember.contactId, + isConnected = isConnected + ) + + fun updateIsConnected(c: Boolean) = + copy(isConnected = c) + + fun updateAuthorInfo(authorInfo: AuthorInfo) = + copy(authorInfo = authorInfo) +} + +fun loadGroupMemberItem(groupMember: GroupMember, connectionRegistry: ConnectionRegistry) = + GroupMemberItem( + groupMember, + groupMember.contactId?.let { connectionRegistry.isConnected(it) } + ) diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/privategroup/sharing/PrivateGroupMemberDrawerContent.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/privategroup/sharing/PrivateGroupMemberDrawerContent.kt index 289cf7a6034a6a855ec998dba79c2d2275640a5d..80d919f5a473556b83c300638dd033e18e9489d5 100644 --- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/privategroup/sharing/PrivateGroupMemberDrawerContent.kt +++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/privategroup/sharing/PrivateGroupMemberDrawerContent.kt @@ -40,14 +40,12 @@ import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import org.briarproject.briar.api.identity.AuthorInfo.Status.OURSELVES -import org.briarproject.briar.api.privategroup.GroupMember import org.briarproject.briar.desktop.contact.ContactItemViewSmall import org.briarproject.briar.desktop.ui.Constants.HEADER_SIZE import org.briarproject.briar.desktop.ui.HorizontalDivider import org.briarproject.briar.desktop.ui.ListItemView import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18nF -import org.briarproject.briar.desktop.utils.UiUtils.getContactDisplayName @Composable fun PrivateGroupMemberDrawerContent( @@ -85,7 +83,7 @@ fun PrivateGroupMemberDrawerContent( } HorizontalDivider() LazyColumn(Modifier.fillMaxSize()) { - items(viewModel.members) { groupMember -> + items(viewModel.members.value) { groupMember -> PrivateGroupMemberListItem(groupMember) } } @@ -93,26 +91,17 @@ fun PrivateGroupMemberDrawerContent( @Composable private fun PrivateGroupMemberListItem( - groupMember: GroupMember, + groupMember: GroupMemberItem, ) = ListItemView { Column( verticalArrangement = spacedBy(8.dp), modifier = Modifier.padding(8.dp) ) { - ContactItemViewSmall( - displayName = groupMember.author.name, - authorId = groupMember.author.id, - authorInfo = groupMember.authorInfo, - // todo: Android shows connection status if contact - isConnected = null, - ) + ContactItemViewSmall(groupMember) if (groupMember.isCreator) { Text( if (groupMember.authorInfo.status == OURSELVES) i18n("group.member.created_you") - else i18nF( - "group.member.created_contact", - getContactDisplayName(groupMember.author.name, groupMember.authorInfo.alias) - ), + else i18nF("group.member.created_contact", groupMember.displayName), style = MaterialTheme.typography.caption, ) } diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/privategroup/sharing/PrivateGroupSharingViewModel.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/privategroup/sharing/PrivateGroupSharingViewModel.kt index 03472b08ec92e903284eeb17a53334dfa8892c7d..29332b8748eed22a1f1c22a3a113516adb2ddfe8 100644 --- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/privategroup/sharing/PrivateGroupSharingViewModel.kt +++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/privategroup/sharing/PrivateGroupSharingViewModel.kt @@ -24,18 +24,22 @@ import androidx.compose.runtime.mutableStateOf 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.ContactAddedEvent +import org.briarproject.bramble.api.contact.event.ContactRemovedEvent import org.briarproject.bramble.api.db.Transaction 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.plugin.event.ContactConnectedEvent import org.briarproject.bramble.api.plugin.event.ContactDisconnectedEvent +import org.briarproject.bramble.api.sync.GroupId import org.briarproject.briar.api.conversation.ConversationManager import org.briarproject.briar.api.identity.AuthorManager -import org.briarproject.briar.api.privategroup.GroupMember +import org.briarproject.briar.api.privategroup.JoinMessageHeader import org.briarproject.briar.api.privategroup.PrivateGroupManager -import org.briarproject.briar.api.privategroup.event.GroupInvitationResponseReceivedEvent +import org.briarproject.briar.api.privategroup.event.GroupMessageAddedEvent import org.briarproject.briar.api.privategroup.invitation.GroupInvitationManager import org.briarproject.briar.api.sharing.SharingConstants.MAX_INVITATION_TEXT_LENGTH import org.briarproject.briar.api.sharing.SharingManager.SharingStatus @@ -49,7 +53,7 @@ import org.briarproject.briar.desktop.threading.UiExecutor import org.briarproject.briar.desktop.utils.InternationalizationUtils import org.briarproject.briar.desktop.utils.StringUtils.takeUtf8 import org.briarproject.briar.desktop.utils.clearAndAddAll -import org.briarproject.briar.desktop.viewmodel.asList +import org.briarproject.briar.desktop.utils.replaceFirst import org.briarproject.briar.desktop.viewmodel.asState import org.briarproject.briar.desktop.viewmodel.update import javax.inject.Inject @@ -57,6 +61,7 @@ import javax.inject.Inject class PrivateGroupSharingViewModel @Inject constructor( private val privateGroupManager: PrivateGroupManager, private val privateGroupInvitationManager: GroupInvitationManager, + private val identityManager: IdentityManager, contactManager: ContactManager, authorManager: AuthorManager, conversationManager: ConversationManager, @@ -80,8 +85,20 @@ class PrivateGroupSharingViewModel @Inject constructor( private val _shareableSelected = mutableStateOf(emptySet<ContactId>()) private val _sharingMessage = mutableStateOf("") - private val _members = mutableStateListOf<GroupMember>() - val members = _members.asList() + private val _isCreator = mutableStateOf(false) + val isCreator = _isCreator.asState() + + private val _members = mutableStateListOf<GroupMemberItem>() + val members = derivedStateOf { + _members.sortedWith( + // first creator of the group (false comes before true) + // second non-case-sensitive, alphabetical order on displayName + compareBy( + { !it.isCreator }, + { it.displayName.lowercase(InternationalizationUtils.locale) } + ) + ) + } data class ShareableContactItem(val status: SharingStatus, val contactItem: ContactItem) @@ -110,14 +127,24 @@ class PrivateGroupSharingViewModel @Inject constructor( } override fun reload() { - super.reload() _shareableSelected.value = emptySet() _sharingMessage.value = "" + reloadMembers() + } + + private fun reloadMembers() { + val groupId = _groupId ?: return runOnDbThreadWithTransaction(true) { txn -> - val members = privateGroupManager.getMembers(txn, _groupId!!) + val isCreator = + privateGroupManager.getPrivateGroup(txn, groupId).creator == identityManager.getLocalAuthor(txn) + val members = privateGroupManager.getMembers(txn, groupId).map { + loadGroupMemberItem(it, connectionRegistry) + } txn.attach { + _isCreator.value = isCreator _members.clearAndAddAll(members) } + loadSharingStatus(txn, groupId, members, isCreator) } } @@ -152,49 +179,70 @@ class PrivateGroupSharingViewModel @Inject constructor( val groupId = _groupId ?: return when { - e is GroupInvitationResponseReceivedEvent && e.messageHeader.shareableId == groupId -> { - if (e.messageHeader.wasAccepted()) { - _sharingStatus.value += e.contactId to SHARING - val connected = connectionRegistry.isConnected(e.contactId) - _sharingInfo.update { addContact(connected) } - } else { - _sharingStatus.value += e.contactId to SHAREABLE - } + // todo: is there any similar leave event we could react to? + e is GroupMessageAddedEvent && e.groupId == groupId && e.header is JoinMessageHeader -> { + reloadMembers() } - // todo: update/reload member list on member join and leave(?) - // e is GroupMessageAddedEvent && e.groupId == groupId && e.header is JoinMessageHeader + e is ContactAddedEvent || e is ContactRemovedEvent -> { + // the newly added or removed contact may be member of the private group + reloadMembers() + } + + // todo: update member list on contact alias/avatar changed (may be member) + // todo: any way of coupling member list to contact list for members that are actually contacts? - // todo: those could be moved to GroupSharingViewModel + // todo: test when leaving groups is implemented e is ContactLeftShareableEvent && e.groupId == groupId -> { - _sharingStatus.value += e.contactId to SHAREABLE + if (_isCreator.value) + _sharingStatus.value += e.contactId to SHAREABLE val connected = connectionRegistry.isConnected(e.contactId) _sharingInfo.update { removeContact(connected) } } e is ContactConnectedEvent -> { - if (_sharingStatus.value[e.contactId] == SHARING) + if (_sharingStatus.value[e.contactId] == SHARING) { _sharingInfo.update { updateContactConnected(true) } + _members.replaceFirst({ it.contactId == e.contactId }) { it.updateIsConnected(true) } + } } e is ContactDisconnectedEvent -> { - if (_sharingStatus.value[e.contactId] == SHARING) + if (_sharingStatus.value[e.contactId] == SHARING) { _sharingInfo.update { updateContactConnected(false) } + _members.replaceFirst({ it.contactId == e.contactId }) { it.updateIsConnected(false) } + } } } } - override fun loadSharingStatus(txn: Transaction) { - val groupId = _groupId ?: return - val map = contactManager.getContacts(txn).associate { contact -> - contact.id to privateGroupInvitationManager.getSharingStatus(txn, contact, groupId) - } - txn.attach { + private fun loadSharingStatus( + txn: Transaction, + groupId: GroupId, + members: List<GroupMemberItem>, + isCreator: Boolean, + ) { + val contacts = contactManager.getContacts(txn) + if (isCreator) { + val map = contacts.associate { contact -> + contact.id to privateGroupInvitationManager.getSharingStatus(txn, contact, groupId) + } val sharing = map.filterValues { it == SHARING }.keys - val online = - sharing.fold(0) { acc, it -> if (connectionRegistry.isConnected(it)) acc + 1 else acc } - _sharingStatus.value = map - _sharingInfo.value = SharingInfo(sharing.size, online) + txn.attach { + val online = + sharing.fold(0) { acc, it -> if (connectionRegistry.isConnected(it)) acc + 1 else acc } + _sharingStatus.value = map + _sharingInfo.value = SharingInfo(sharing.size, online) + } + } else { + val sharing = members.mapNotNull { it.contactId } + val map = sharing.associateWith { SHARING } + txn.attach { + val online = + sharing.fold(0) { acc, it -> if (connectionRegistry.isConnected(it)) acc + 1 else acc } + _sharingStatus.value = map + _sharingInfo.value = SharingInfo(sharing.size, online) + } } } } diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/threadedgroup/sharing/ThreadedGroupSharingViewModel.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/threadedgroup/sharing/ThreadedGroupSharingViewModel.kt index fe9f1379352e71bdbe3d7c18d2f72a1bddfebea1..d9aaedd0bace9bb5d0b8412cfff312c40dba6cc2 100644 --- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/threadedgroup/sharing/ThreadedGroupSharingViewModel.kt +++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/threadedgroup/sharing/ThreadedGroupSharingViewModel.kt @@ -21,7 +21,6 @@ package org.briarproject.briar.desktop.threadedgroup.sharing import androidx.compose.runtime.mutableStateOf import org.briarproject.bramble.api.connection.ConnectionRegistry import org.briarproject.bramble.api.contact.ContactManager -import org.briarproject.bramble.api.db.Transaction import org.briarproject.bramble.api.db.TransactionManager import org.briarproject.bramble.api.event.EventBus import org.briarproject.bramble.api.lifecycle.LifecycleManager @@ -69,19 +68,7 @@ abstract class ThreadedGroupSharingViewModel( reload() } - protected open fun reload() { - loadSharingStatus() - } - - override fun loadContactsWithinTransaction(txn: Transaction) { - super.loadContactsWithinTransaction(txn) - if (_groupId != null) loadSharingStatus(txn) - } - - private fun loadSharingStatus(): Unit = - runOnDbThreadWithTransaction(true, this::loadSharingStatus) - - protected abstract fun loadSharingStatus(txn: Transaction) + protected abstract fun reload() data class SharingInfo(val total: Int, val online: Int) { fun addContact(connected: Boolean) = copy( diff --git a/briar-desktop/src/test/kotlin/org/briarproject/briar/desktop/TestUtils.kt b/briar-desktop/src/test/kotlin/org/briarproject/briar/desktop/TestUtils.kt index e8347b9cce5e8df403ad83342124f9c0debe69d1..7b67c41088bf72984d4c04b867e34fdc92123d46 100644 --- a/briar-desktop/src/test/kotlin/org/briarproject/briar/desktop/TestUtils.kt +++ b/briar-desktop/src/test/kotlin/org/briarproject/briar/desktop/TestUtils.kt @@ -1,6 +1,6 @@ /* * Briar Desktop - * Copyright (C) 2021-2022 The Briar Project + * Copyright (C) 2021-2023 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