Improvement

This commit is contained in:
SessionHero01 2024-10-21 10:59:31 +11:00
parent b8e98ec9ed
commit 4536aca327
No known key found for this signature in database
11 changed files with 171 additions and 135 deletions

View File

@ -1,8 +1,10 @@
package org.thoughtcrime.securesms.groups package org.thoughtcrime.securesms.groups
import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -19,6 +21,7 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class CreateGroupViewModel @Inject constructor( class CreateGroupViewModel @Inject constructor(
configFactory: ConfigFactory, configFactory: ConfigFactory,
@ApplicationContext appContext: Context,
private val storage: StorageProtocol, private val storage: StorageProtocol,
private val groupManagerV2: GroupManagerV2, private val groupManagerV2: GroupManagerV2,
): ViewModel() { ): ViewModel() {
@ -28,6 +31,7 @@ class CreateGroupViewModel @Inject constructor(
configFactory = configFactory, configFactory = configFactory,
excludingAccountIDs = emptySet(), excludingAccountIDs = emptySet(),
scope = viewModelScope, scope = viewModelScope,
appContext = appContext,
) )
// Input: group name // Input: group name

View File

@ -1,31 +0,0 @@
package org.thoughtcrime.securesms.groups
import androidx.lifecycle.ViewModel
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.contacts.Contact
@HiltViewModel(assistedFactory = EditGroupInviteViewModel.Factory::class)
class EditGroupInviteViewModel @AssistedInject constructor(
@Assisted private val groupSessionId: String,
private val storage: StorageProtocol
): ViewModel() {
@AssistedFactory
interface Factory {
fun create(groupSessionId: String): EditGroupInviteViewModel
}
}
data class EditGroupInviteState(
val viewState: EditGroupInviteViewState,
)
data class EditGroupInviteViewState(
val currentMembers: List<GroupMemberState>,
val allContacts: Set<Contact>
)

View File

@ -24,7 +24,6 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.libsession_util.util.Contact
import network.loki.messenger.libsession_util.util.GroupDisplayInfo import network.loki.messenger.libsession_util.util.GroupDisplayInfo
import network.loki.messenger.libsession_util.util.GroupMember import network.loki.messenger.libsession_util.util.GroupMember
import org.session.libsession.database.StorageProtocol import org.session.libsession.database.StorageProtocol
@ -67,9 +66,9 @@ class EditGroupViewModel @AssistedInject constructor(
memberPendingState memberPendingState
) { _, pending -> ) { _, pending ->
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val currentUserId = checkNotNull(storage.getUserPublicKey()) { val currentUserId = AccountId(checkNotNull(storage.getUserPublicKey()) {
"User public key is null" "User public key is null"
} })
val displayInfo = storage.getClosedGroupDisplayInfo(groupId.hexString) val displayInfo = storage.getClosedGroupDisplayInfo(groupId.hexString)
?: return@withContext null ?: return@withContext null
@ -118,21 +117,21 @@ class EditGroupViewModel @AssistedInject constructor(
val error: StateFlow<String?> get() = mutableError val error: StateFlow<String?> get() = mutableError
// Output: // Output:
val excludingAccountIDsFromContactSelection: Set<String> val excludingAccountIDsFromContactSelection: Set<AccountId>
get() = groupInfo.value?.second?.mapTo(hashSetOf()) { it.accountId }.orEmpty() get() = groupInfo.value?.second?.mapTo(hashSetOf()) { it.accountId }.orEmpty()
private fun createGroupMember( private fun createGroupMember(
member: GroupMember, member: GroupMember,
myAccountId: String, myAccountId: AccountId,
amIAdmin: Boolean, amIAdmin: Boolean,
pendingState: MemberPendingState? pendingState: MemberPendingState?
): GroupMemberState { ): GroupMemberState {
var status = "" var status = ""
var highlightStatus = false var highlightStatus = false
var name = member.name.orEmpty() var name = member.name.orEmpty().ifEmpty { member.sessionId }
when { when {
member.sessionId == myAccountId -> { member.sessionId == myAccountId.hexString -> {
name = context.getString(R.string.you) name = context.getString(R.string.you)
} }
@ -164,81 +163,81 @@ class EditGroupViewModel @AssistedInject constructor(
} }
return GroupMemberState( return GroupMemberState(
accountId = member.sessionId, accountId = AccountId(member.sessionId),
name = name, name = name,
canRemove = amIAdmin && member.sessionId != myAccountId && !member.isAdminOrBeingPromoted, canRemove = amIAdmin && member.sessionId != myAccountId.hexString && !member.isAdminOrBeingPromoted,
canPromote = amIAdmin && member.sessionId != myAccountId && !member.isAdminOrBeingPromoted, canPromote = amIAdmin && member.sessionId != myAccountId.hexString && !member.isAdminOrBeingPromoted,
canResendPromotion = amIAdmin && member.sessionId != myAccountId && member.promotionFailed, canResendPromotion = amIAdmin && member.sessionId != myAccountId.hexString && member.promotionFailed,
canResendInvite = amIAdmin && member.sessionId != myAccountId && canResendInvite = amIAdmin && member.sessionId != myAccountId.hexString &&
(member.inviteFailed || member.invitePending), (member.inviteFailed || member.invitePending),
status = status, status = status,
highlightStatus = highlightStatus highlightStatus = highlightStatus
) )
} }
private fun sortMembers(members: MutableList<GroupMember>, currentUserId: String) { private fun sortMembers(members: MutableList<GroupMember>, currentUserId: AccountId) {
members.sortWith( members.sortWith(
compareBy( compareBy(
{ !it.inviteFailed }, // Failed invite comes first (as false value is less than true) { !it.inviteFailed }, // Failed invite comes first (as false value is less than true)
{ memberPendingState.value[AccountId(it.sessionId)] != MemberPendingState.Inviting }, // "Sending invite" comes first { memberPendingState.value[AccountId(it.sessionId)] != MemberPendingState.Inviting }, // "Sending invite" comes first
{ !it.invitePending }, // "Invite sent" comes first { !it.invitePending }, // "Invite sent" comes first
{ !it.isAdminOrBeingPromoted }, // Admins come first { !it.isAdminOrBeingPromoted }, // Admins come first
{ it.sessionId != currentUserId }, // Being myself comes first { it.sessionId != currentUserId.hexString }, // Being myself comes first
{ it.name }, // Sort by name { it.name }, // Sort by name
{ it.sessionId } // Last resort: sort by account ID { it.sessionId } // Last resort: sort by account ID
) )
) )
} }
fun onContactSelected(contacts: Set<Contact>) { fun onContactSelected(contacts: Set<AccountId>) {
performGroupOperation { performGroupOperation {
try { try {
// Mark the contacts as pending // Mark the contacts as pending
memberPendingState.update { states -> memberPendingState.update { states ->
states + contacts.associate { AccountId(it.id) to MemberPendingState.Inviting } states + contacts.associateWith { MemberPendingState.Inviting }
} }
groupManager.inviteMembers( groupManager.inviteMembers(
groupId, groupId,
contacts.map { AccountId(it.id) }, contacts.toList(),
shareHistory = false shareHistory = false
) )
} finally { } finally {
// Remove pending state (so the real state will be revealed) // Remove pending state (so the real state will be revealed)
memberPendingState.update { states -> states - contacts.mapTo(hashSetOf()) { AccountId(it.id) } } memberPendingState.update { states -> states - contacts }
} }
} }
} }
fun onResendInviteClicked(contactSessionId: String) { fun onResendInviteClicked(contactSessionId: AccountId) {
onContactSelected(setOf(Contact(contactSessionId))) onContactSelected(setOf(contactSessionId))
} }
fun onPromoteContact(memberSessionId: String) { fun onPromoteContact(memberSessionId: AccountId) {
performGroupOperation { performGroupOperation {
try { try {
memberPendingState.update { states -> memberPendingState.update { states ->
states + (AccountId(memberSessionId) to MemberPendingState.Promoting) states + (memberSessionId to MemberPendingState.Promoting)
} }
groupManager.promoteMember(groupId, listOf(AccountId(memberSessionId))) groupManager.promoteMember(groupId, listOf(memberSessionId))
} finally { } finally {
memberPendingState.update { states -> states - AccountId(memberSessionId) } memberPendingState.update { states -> states - memberSessionId }
} }
} }
} }
fun onRemoveContact(contactSessionId: String, removeMessages: Boolean) { fun onRemoveContact(contactSessionId: AccountId, removeMessages: Boolean) {
performGroupOperation { performGroupOperation {
groupManager.removeMembers( groupManager.removeMembers(
groupAccountId = groupId, groupAccountId = groupId,
removedMembers = listOf(AccountId(contactSessionId)), removedMembers = listOf(contactSessionId),
removeMessages = removeMessages removeMessages = removeMessages
) )
} }
} }
fun onResendPromotionClicked(memberSessionId: String) { fun onResendPromotionClicked(memberSessionId: AccountId) {
onPromoteContact(memberSessionId) onPromoteContact(memberSessionId)
} }
@ -315,7 +314,7 @@ private enum class MemberPendingState {
} }
data class GroupMemberState( data class GroupMemberState(
val accountId: String, val accountId: AccountId,
val name: String, val name: String,
val status: String, val status: String,
val highlightStatus: Boolean, val highlightStatus: Boolean,

View File

@ -6,9 +6,11 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE
import network.loki.messenger.libsession_util.util.Contact
import network.loki.messenger.libsession_util.util.Conversation import network.loki.messenger.libsession_util.util.Conversation
import network.loki.messenger.libsession_util.util.GroupInfo import network.loki.messenger.libsession_util.util.GroupInfo
import network.loki.messenger.libsession_util.util.GroupMember import network.loki.messenger.libsession_util.util.GroupMember
@ -24,6 +26,7 @@ import org.session.libsession.messaging.messages.Destination
import org.session.libsession.messaging.messages.control.GroupUpdated import org.session.libsession.messaging.messages.control.GroupUpdated
import org.session.libsession.messaging.messages.visible.Profile import org.session.libsession.messaging.messages.visible.Profile
import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPoller
import org.session.libsession.messaging.utilities.MessageAuthentication.buildDeleteMemberContentSignature import org.session.libsession.messaging.utilities.MessageAuthentication.buildDeleteMemberContentSignature
import org.session.libsession.messaging.utilities.MessageAuthentication.buildInfoChangeVerifier import org.session.libsession.messaging.utilities.MessageAuthentication.buildInfoChangeVerifier
import org.session.libsession.messaging.utilities.MessageAuthentication.buildMemberChangeSignature import org.session.libsession.messaging.utilities.MessageAuthentication.buildMemberChangeSignature
@ -88,7 +91,7 @@ class GroupManagerV2Impl @Inject constructor(
override suspend fun createGroup( override suspend fun createGroup(
groupName: String, groupName: String,
groupDescription: String, groupDescription: String,
members: Set<Contact> members: Set<AccountId>
): Recipient = withContext(dispatcher) { ): Recipient = withContext(dispatcher) {
val ourAccountId = val ourAccountId =
requireNotNull(storage.getUserPublicKey()) { "Our account ID is not available" } requireNotNull(storage.getUserPublicKey()) { "Our account ID is not available" }
@ -103,9 +106,13 @@ class GroupManagerV2Impl @Inject constructor(
.also(configs.userGroups::set) .also(configs.userGroups::set)
} }
checkNotNull(group.adminKey) { "Admin key is null for new group creation." } val adminKey = checkNotNull(group.adminKey) { "Admin key is null for new group creation." }
val groupId = group.groupAccountId val groupId = group.groupAccountId
val memberAsRecipients = members.map {
Recipient.from(application, Address.fromSerialized(it.hexString), false)
}
try { try {
configFactory.withMutableGroupConfigs(groupId) { configs -> configFactory.withMutableGroupConfigs(groupId) { configs ->
// Update group's information // Update group's information
@ -113,12 +120,14 @@ class GroupManagerV2Impl @Inject constructor(
configs.groupInfo.setDescription(groupDescription) configs.groupInfo.setDescription(groupDescription)
// Add members // Add members
for (member in members) { for (member in memberAsRecipients) {
configs.groupMembers.set( configs.groupMembers.set(
GroupMember( GroupMember(
sessionId = member.id, sessionId = member.address.serialize(),
name = member.name, name = member.name,
profilePicture = member.profilePicture ?: UserPic.DEFAULT, profilePicture = member.profileAvatar?.let { url ->
member.profileKey?.let { key -> UserPic(url, key) }
} ?: UserPic.DEFAULT,
inviteStatus = INVITE_STATUS_SENT inviteStatus = INVITE_STATUS_SENT
) )
) )
@ -165,10 +174,13 @@ class GroupManagerV2Impl @Inject constructor(
JobQueue.shared.add( JobQueue.shared.add(
InviteContactsJob( InviteContactsJob(
groupSessionId = groupId.hexString, groupSessionId = groupId.hexString,
memberSessionIds = members.map { it.id }.toTypedArray() memberSessionIds = members.map { it.hexString }.toTypedArray()
) )
) )
// Also send a group update message
sendGroupUpdateForAddingMembers(groupId, adminKey, members, insertLocally = false)
recipient recipient
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to create group", e) Log.e(TAG, "Failed to create group", e)
@ -223,7 +235,7 @@ class GroupManagerV2Impl @Inject constructor(
configs.groupMembers.set(toSet) configs.groupMembers.set(toSet)
} }
// Depends on whether we want to share history, we may need to rekey or just adding supplement keys // Depends on whether we want to share history, we may need to rekey or just adding rsupplement keys
if (shareHistory) { if (shareHistory) {
val memberKey = configs.groupKeys.supplementFor(newMembers.map { it.hexString }) val memberKey = configs.groupKeys.supplementFor(newMembers.map { it.hexString })
batchRequests.add( batchRequests.add(
@ -267,7 +279,19 @@ class GroupManagerV2Impl @Inject constructor(
) )
) )
// Send a member change message to the group // Send a group update message to the group telling members someone has been invited
sendGroupUpdateForAddingMembers(group, adminKey, newMembers, insertLocally = true)
}
/**
* Send a group update message to the group telling members someone has been invited.
*/
private fun sendGroupUpdateForAddingMembers(
group: AccountId,
adminKey: ByteArray,
newMembers: Collection<AccountId>,
insertLocally: Boolean
) {
val timestamp = clock.currentTimeMills() val timestamp = clock.currentTimeMills()
val signature = SodiumUtilities.sign( val signature = SodiumUtilities.sign(
buildMemberChangeSignature(GroupUpdateMemberChangeMessage.Type.ADDED, timestamp), buildMemberChangeSignature(GroupUpdateMemberChangeMessage.Type.ADDED, timestamp),
@ -284,9 +308,12 @@ class GroupManagerV2Impl @Inject constructor(
) )
.build() .build()
).apply { this.sentTimestamp = timestamp } ).apply { this.sentTimestamp = timestamp }
MessageSender.send(updatedMessage, Destination.ClosedGroup(group.hexString), false).await() MessageSender.send(updatedMessage, Destination.ClosedGroup(group.hexString), false)
if (insertLocally) {
storage.insertGroupInfoChange(updatedMessage, group) storage.insertGroupInfoChange(updatedMessage, group)
} }
}
override suspend fun removeMembers( override suspend fun removeMembers(
groupAccountId: AccountId, groupAccountId: AccountId,
@ -529,16 +556,15 @@ class GroupManagerV2Impl @Inject constructor(
lokiDatabase.deleteGroupInviteReferrer(threadId) lokiDatabase.deleteGroupInviteReferrer(threadId)
if (approved) { if (approved) {
approveGroupInvite(group, threadId) approveGroupInvite(group)
} else { } else {
configFactory.withMutableUserConfigs { it.userGroups.eraseClosedGroup(groupId.hexString) } configFactory.withMutableUserConfigs { it.userGroups.eraseClosedGroup(groupId.hexString) }
storage.deleteConversation(threadId) storage.deleteConversation(threadId)
} }
} }
private fun approveGroupInvite( private suspend fun approveGroupInvite(
group: GroupInfo.ClosedGroupInfo, group: GroupInfo.ClosedGroupInfo,
threadId: Long,
) { ) {
val key = requireNotNull(storage.getUserPublicKey()) { val key = requireNotNull(storage.getUserPublicKey()) {
"Our account ID is not available" "Our account ID is not available"
@ -549,6 +575,15 @@ class GroupManagerV2Impl @Inject constructor(
configs.userGroups.set(group.copy(invited = false)) configs.userGroups.set(group.copy(invited = false))
} }
val poller = checkNotNull(pollerFactory.pollerFor(group.groupAccountId)) { "Unable to start a poller for groups " }
poller.start()
// We need to wait until we have the first data polled from the poller, otherwise
// we won't have the necessary configs to send invite response/or do anything else
poller.state.filterIsInstance<ClosedGroupPoller.StartedState>()
.filter { it.hadAtLeastOneSuccessfulPoll }
.first()
if (group.adminKey == null) { if (group.adminKey == null) {
// Send an invite response to the group if we are invited as a regular member // Send an invite response to the group if we are invited as a regular member
val inviteResponse = GroupUpdateInviteResponseMessage.newBuilder() val inviteResponse = GroupUpdateInviteResponseMessage.newBuilder()
@ -572,8 +607,6 @@ class GroupManagerV2Impl @Inject constructor(
Unit Unit
} }
} }
pollerFactory.pollerFor(group.groupAccountId)?.start()
} }
override suspend fun handleInvitation( override suspend fun handleInvitation(
@ -656,7 +689,7 @@ class GroupManagerV2Impl @Inject constructor(
* @param inviter the invite message sender * @param inviter the invite message sender
* @return The newly created group info if the invitation is processed, null otherwise. * @return The newly created group info if the invitation is processed, null otherwise.
*/ */
private fun handleInvitation( private suspend fun handleInvitation(
groupId: AccountId, groupId: AccountId,
groupName: String, groupName: String,
authDataOrAdminKey: ByteArray, authDataOrAdminKey: ByteArray,
@ -691,8 +724,9 @@ class GroupManagerV2Impl @Inject constructor(
val groupThreadId = storage.getOrCreateThreadIdFor(recipient.address) val groupThreadId = storage.getOrCreateThreadIdFor(recipient.address)
storage.setRecipientApprovedMe(recipient, true) storage.setRecipientApprovedMe(recipient, true)
storage.setRecipientApproved(recipient, shouldAutoApprove) storage.setRecipientApproved(recipient, shouldAutoApprove)
if (shouldAutoApprove) { if (shouldAutoApprove) {
approveGroupInvite(closedGroupInfo, groupThreadId) approveGroupInvite(closedGroupInfo)
} else { } else {
lokiDatabase.addGroupInviteReferrer(groupThreadId, inviter.hexString) lokiDatabase.addGroupInviteReferrer(groupThreadId, inviter.hexString)
storage.insertGroupInviteControlMessage( storage.insertGroupInviteControlMessage(

View File

@ -1,11 +1,13 @@
package org.thoughtcrime.securesms.groups package org.thoughtcrime.securesms.groups
import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
@ -21,24 +23,28 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import network.loki.messenger.libsession_util.util.Contact
import org.session.libsession.database.StorageProtocol import org.session.libsession.database.StorageProtocol
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsession.utilities.truncateIdForDisplay import org.session.libsession.utilities.truncateIdForDisplay
import org.session.libsignal.utilities.AccountId
import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.home.search.getSearchName
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
@HiltViewModel(assistedFactory = SelectContactsViewModel.Factory::class) @HiltViewModel(assistedFactory = SelectContactsViewModel.Factory::class)
class SelectContactsViewModel @AssistedInject constructor( class SelectContactsViewModel @AssistedInject constructor(
private val storage: StorageProtocol, private val storage: StorageProtocol,
private val configFactory: ConfigFactory, private val configFactory: ConfigFactory,
@Assisted private val excludingAccountIDs: Set<String>, @ApplicationContext private val appContext: Context,
@Assisted private val excludingAccountIDs: Set<AccountId>,
@Assisted private val scope: CoroutineScope @Assisted private val scope: CoroutineScope
) : ViewModel() { ) : ViewModel() {
// Input: The search query // Input: The search query
private val mutableSearchQuery = MutableStateFlow("") private val mutableSearchQuery = MutableStateFlow("")
// Input: The selected contact account IDs // Input: The selected contact account IDs
private val mutableSelectedContactAccountIDs = MutableStateFlow(emptySet<String>()) private val mutableSelectedContactAccountIDs = MutableStateFlow(emptySet<AccountId>())
// Output: The search query // Output: The search query
val searchQuery: StateFlow<String> get() = mutableSearchQuery val searchQuery: StateFlow<String> get() = mutableSearchQuery
@ -52,11 +58,11 @@ class SelectContactsViewModel @AssistedInject constructor(
).stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) ).stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
// Output // Output
val currentSelected: Set<Contact> val currentSelected: Set<AccountId>
get() = contacts.value get() = contacts.value
.asSequence() .asSequence()
.filter { it.selected } .filter { it.selected }
.map { it.contact } .map { it.accountID }
.toSet() .toSet()
override fun onCleared() { override fun onCleared() {
@ -71,34 +77,32 @@ class SelectContactsViewModel @AssistedInject constructor(
.map { .map {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val allContacts = configFactory.withUserConfigs { val allContacts = configFactory.withUserConfigs {
it.contacts.all() it.contacts.all().filter { it.approvedMe }
} }
if (excludingAccountIDs.isEmpty()) { if (excludingAccountIDs.isEmpty()) {
allContacts allContacts
} else { } else {
allContacts.filterNot { it.id in excludingAccountIDs } allContacts.filterNot { AccountId(it.id) in excludingAccountIDs }
} }.map { Recipient.from(appContext, Address.fromSerialized(it.id), false) }
} }
} }
private fun filterContacts( private fun filterContacts(
contacts: Collection<Contact>, contacts: Collection<Recipient>,
query: String, query: String,
selectedAccountIDs: Set<String> selectedAccountIDs: Set<AccountId>
): List<ContactItem> { ): List<ContactItem> {
return contacts return contacts
.asSequence() .asSequence()
.filter { .filter { query.isBlank() || it.getSearchName().contains(query, ignoreCase = true) }
query.isBlank() ||
it.name.contains(query, ignoreCase = true) ||
it.nickname.contains(query, ignoreCase = true)
}
.map { contact -> .map { contact ->
val accountId = AccountId(contact.address.serialize())
ContactItem( ContactItem(
contact = contact, name = contact.getSearchName(),
selected = selectedAccountIDs.contains(contact.id) accountID = accountId,
selected = selectedAccountIDs.contains(accountId),
) )
} }
.toList() .toList()
@ -108,7 +112,7 @@ class SelectContactsViewModel @AssistedInject constructor(
mutableSearchQuery.value = query mutableSearchQuery.value = query
} }
fun onContactItemClicked(accountID: String) { fun onContactItemClicked(accountID: AccountId) {
val newSet = mutableSelectedContactAccountIDs.value.toHashSet() val newSet = mutableSelectedContactAccountIDs.value.toHashSet()
if (!newSet.remove(accountID)) { if (!newSet.remove(accountID)) {
newSet.add(accountID) newSet.add(accountID)
@ -119,16 +123,14 @@ class SelectContactsViewModel @AssistedInject constructor(
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create( fun create(
excludingAccountIDs: Set<String> = emptySet(), excludingAccountIDs: Set<AccountId> = emptySet(),
scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate), scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
): SelectContactsViewModel ): SelectContactsViewModel
} }
} }
data class ContactItem( data class ContactItem(
val contact: Contact, val accountID: AccountId,
val name: String,
val selected: Boolean, val selected: Boolean,
) { )
val accountID: String get() = contact.id
val name: String get() = contact.displayName.ifEmpty { truncateIdForDisplay(contact.id) }
}

View File

@ -41,6 +41,7 @@ import network.loki.messenger.R
import network.loki.messenger.libsession_util.util.Contact import network.loki.messenger.libsession_util.util.Contact
import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.AccountId
import org.thoughtcrime.securesms.groups.ContactItem import org.thoughtcrime.securesms.groups.ContactItem
import org.thoughtcrime.securesms.ui.Avatar import org.thoughtcrime.securesms.ui.Avatar
import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalColors
@ -69,7 +70,7 @@ fun GroupMinimumVersionBanner(modifier: Modifier = Modifier) {
fun LazyListScope.multiSelectMemberList( fun LazyListScope.multiSelectMemberList(
contacts: List<ContactItem>, contacts: List<ContactItem>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onContactItemClicked: (accountId: String) -> Unit, onContactItemClicked: (accountId: AccountId) -> Unit,
enabled: Boolean = true, enabled: Boolean = true,
) { ) {
items(contacts) { contact -> items(contacts) { contact ->
@ -120,7 +121,7 @@ fun RowScope.MemberName(
@Composable @Composable
fun RowScope.ContactPhoto(sessionId: String) { fun RowScope.ContactPhoto(sessionId: AccountId) {
return if (LocalInspectionMode.current) { return if (LocalInspectionMode.current) {
Image( Image(
painterResource(id = R.drawable.ic_profile_default), painterResource(id = R.drawable.ic_profile_default),
@ -136,7 +137,7 @@ fun RowScope.ContactPhoto(sessionId: String) {
val context = LocalContext.current val context = LocalContext.current
// Ideally we migrate to something that doesn't require recipient, or get contact photo another way // Ideally we migrate to something that doesn't require recipient, or get contact photo another way
val recipient = remember(sessionId) { val recipient = remember(sessionId) {
Recipient.from(context, Address.fromSerialized(sessionId), false) Recipient.from(context, Address.fromSerialized(sessionId.hexString), false)
} }
Avatar(recipient, modifier = Modifier.size(48.dp)) Avatar(recipient, modifier = Modifier.size(48.dp))
} }
@ -153,11 +154,13 @@ fun PreviewMemberList() {
multiSelectMemberList( multiSelectMemberList(
contacts = listOf( contacts = listOf(
ContactItem( ContactItem(
Contact(random, "Person"), accountID = AccountId(random),
name = "Person",
selected = false, selected = false,
), ),
ContactItem( ContactItem(
Contact(random, "Cow"), accountID = AccountId(random),
name = "Cow",
selected = true, selected = true,
) )
), ),

View File

@ -21,7 +21,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.libsession_util.util.Contact import org.session.libsignal.utilities.AccountId
import org.thoughtcrime.securesms.groups.ContactItem import org.thoughtcrime.securesms.groups.ContactItem
import org.thoughtcrime.securesms.groups.CreateGroupEvent import org.thoughtcrime.securesms.groups.CreateGroupEvent
import org.thoughtcrime.securesms.groups.CreateGroupViewModel import org.thoughtcrime.securesms.groups.CreateGroupViewModel
@ -83,7 +83,7 @@ fun CreateGroup(
groupNameError: String, groupNameError: String,
contactSearchQuery: String, contactSearchQuery: String,
onContactSearchQueryChanged: (String) -> Unit, onContactSearchQueryChanged: (String) -> Unit,
onContactItemClicked: (accountID: String) -> Unit, onContactItemClicked: (accountID: AccountId) -> Unit,
showLoading: Boolean, showLoading: Boolean,
items: List<ContactItem>, items: List<ContactItem>,
onCreateClicked: () -> Unit, onCreateClicked: () -> Unit,
@ -144,8 +144,8 @@ private fun CreateGroupPreview(
) { ) {
val random = "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234" val random = "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"
val previewMembers = listOf( val previewMembers = listOf(
ContactItem(Contact(random, name = "Alice"), false), ContactItem(accountID = AccountId(random), name = "Alice", false),
ContactItem(Contact(random, name = "Bob"), true), ContactItem(accountID = AccountId(random), name = "Bob", true),
) )
PreviewTheme { PreviewTheme {

View File

@ -114,10 +114,10 @@ private object RouteEditGroup
fun EditGroup( fun EditGroup(
onBackClick: () -> Unit, onBackClick: () -> Unit,
onAddMemberClick: () -> Unit, onAddMemberClick: () -> Unit,
onResendInviteClick: (accountId: String) -> Unit, onResendInviteClick: (accountId: AccountId) -> Unit,
onResendPromotionClick: (accountId: String) -> Unit, onResendPromotionClick: (accountId: AccountId) -> Unit,
onPromoteClick: (accountId: String) -> Unit, onPromoteClick: (accountId: AccountId) -> Unit,
onRemoveClick: (accountId: String, removeMessages: Boolean) -> Unit, onRemoveClick: (accountId: AccountId, removeMessages: Boolean) -> Unit,
onEditingNameValueChanged: (String) -> Unit, onEditingNameValueChanged: (String) -> Unit,
editingName: String?, editingName: String?,
onEditNameClicked: () -> Unit, onEditNameClicked: () -> Unit,
@ -300,7 +300,7 @@ fun EditGroup(
@Composable @Composable
private fun ConfirmRemovingMemberDialog( private fun ConfirmRemovingMemberDialog(
onConfirmed: (accountId: String, removeMessages: Boolean) -> Unit, onConfirmed: (accountId: AccountId, removeMessages: Boolean) -> Unit,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
member: GroupMemberState, member: GroupMemberState,
groupName: String, groupName: String,
@ -393,7 +393,7 @@ private fun MemberModalBottomSheetOptionItem(
@Composable @Composable
private fun MemberItem( private fun MemberItem(
onClick: (accountId: String) -> Unit, onClick: (accountId: AccountId) -> Unit,
member: GroupMemberState, member: GroupMemberState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -445,7 +445,7 @@ private fun MemberItem(
private fun EditGroupPreview() { private fun EditGroupPreview() {
PreviewTheme { PreviewTheme {
val oneMember = GroupMemberState( val oneMember = GroupMemberState(
accountId = "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"),
name = "Test User", name = "Test User",
status = "Invited", status = "Invited",
highlightStatus = false, highlightStatus = false,
@ -455,7 +455,7 @@ private fun EditGroupPreview() {
canResendPromotion = false, canResendPromotion = false,
) )
val twoMember = GroupMemberState( val twoMember = GroupMemberState(
accountId = "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235", accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235"),
name = "Test User 2", name = "Test User 2",
status = "Promote failed", status = "Promote failed",
highlightStatus = true, highlightStatus = true,
@ -465,7 +465,7 @@ private fun EditGroupPreview() {
canResendPromotion = false, canResendPromotion = false,
) )
val threeMember = GroupMemberState( val threeMember = GroupMemberState(
accountId = "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1236", accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1236"),
name = "Test User 3", name = "Test User 3",
status = "", status = "",
highlightStatus = false, highlightStatus = false,

View File

@ -23,7 +23,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.libsession_util.util.Contact import org.session.libsignal.utilities.AccountId
import org.thoughtcrime.securesms.groups.ContactItem import org.thoughtcrime.securesms.groups.ContactItem
import org.thoughtcrime.securesms.groups.SelectContactsViewModel import org.thoughtcrime.securesms.groups.SelectContactsViewModel
import org.thoughtcrime.securesms.ui.SearchBar import org.thoughtcrime.securesms.ui.SearchBar
@ -38,8 +38,8 @@ object RouteSelectContacts
@Composable @Composable
fun SelectContactsScreen( fun SelectContactsScreen(
excludingAccountIDs: Set<String> = emptySet(), excludingAccountIDs: Set<AccountId> = emptySet(),
onDoneClicked: (selectedContacts: Set<Contact>) -> Unit, onDoneClicked: (selectedContacts: Set<AccountId>) -> Unit,
onBackClicked: () -> Unit, onBackClicked: () -> Unit,
) { ) {
val viewModel = hiltViewModel<SelectContactsViewModel, SelectContactsViewModel.Factory> { factory -> val viewModel = hiltViewModel<SelectContactsViewModel, SelectContactsViewModel.Factory> { factory ->
@ -60,7 +60,7 @@ fun SelectContactsScreen(
@Composable @Composable
fun SelectContacts( fun SelectContacts(
contacts: List<ContactItem>, contacts: List<ContactItem>,
onContactItemClicked: (accountId: String) -> Unit, onContactItemClicked: (accountId: AccountId) -> Unit,
searchQuery: String, searchQuery: String,
onSearchQueryChanged: (String) -> Unit, onSearchQueryChanged: (String) -> Unit,
onDoneClicked: () -> Unit, onDoneClicked: () -> Unit,
@ -117,15 +117,19 @@ fun SelectContacts(
@Preview @Preview
@Composable @Composable
private fun PreviewSelectContacts() { private fun PreviewSelectContacts() {
val random = "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"
PreviewTheme { PreviewTheme {
SelectContacts( SelectContacts(
contacts = listOf( contacts = listOf(
ContactItem( ContactItem(
contact = Contact(id = "123", name = "User 1"), accountID = AccountId(random),
name = "User 1",
selected = false, selected = false,
), ),
ContactItem( ContactItem(
contact = Contact(id = "124", name = "User 2"), accountID = AccountId(random),
name = "User 2",
selected = true, selected = true,
), ),
), ),

View File

@ -1,6 +1,5 @@
package org.session.libsession.messaging.groups package org.session.libsession.messaging.groups
import network.loki.messenger.libsession_util.util.Contact
import org.session.libsession.messaging.messages.control.GroupUpdated import org.session.libsession.messaging.messages.control.GroupUpdated
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateDeleteMemberContentMessage import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateDeleteMemberContentMessage
@ -14,7 +13,7 @@ interface GroupManagerV2 {
suspend fun createGroup( suspend fun createGroup(
groupName: String, groupName: String,
groupDescription: String, groupDescription: String,
members: Set<Contact> members: Set<AccountId>
): Recipient ): Recipient
suspend fun inviteMembers( suspend fun inviteMembers(

View File

@ -6,6 +6,8 @@ import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.supervisorScope
@ -49,23 +51,31 @@ class ClosedGroupPoller(
private const val TAG = "ClosedGroupPoller" private const val TAG = "ClosedGroupPoller"
} }
private var job: Job? = null sealed interface State
data object IdleState : State
data class StartedState(internal val job: Job, val hadAtLeastOneSuccessfulPoll: Boolean = false) : State
private val mutableState = MutableStateFlow<State>(IdleState)
val state: StateFlow<State> get() = mutableState
fun start() { fun start() {
if (job?.isActive == true) return // already started, don't restart if ((state.value as? StartedState)?.job?.isActive == true) return // already started, don't restart
Log.d(TAG, "Starting closed group poller for ${closedGroupSessionId.hexString.take(4)}") Log.d(TAG, "Starting closed group poller for ${closedGroupSessionId.hexString.take(4)}")
job?.cancel() val job = scope.launch(executor) {
job = scope.launch(executor) {
while (isActive) { while (isActive) {
try { try {
val swarmNodes = SnodeAPI.fetchSwarmNodes(closedGroupSessionId.hexString).toMutableSet() val swarmNodes =
SnodeAPI.fetchSwarmNodes(closedGroupSessionId.hexString).toMutableSet()
var currentSnode: Snode? = null var currentSnode: Snode? = null
while (isActive) { while (isActive) {
if (currentSnode == null) { if (currentSnode == null) {
check(swarmNodes.isNotEmpty()) { "No more swarm nodes found" } check(swarmNodes.isNotEmpty()) { "No more swarm nodes found" }
Log.d(TAG, "No current snode, getting a new one. Remaining in pool = ${swarmNodes.size - 1}") Log.d(
TAG,
"No current snode, getting a new one. Remaining in pool = ${swarmNodes.size - 1}"
)
currentSnode = swarmNodes.random() currentSnode = swarmNodes.random()
swarmNodes.remove(currentSnode) swarmNodes.remove(currentSnode)
} }
@ -97,11 +107,17 @@ class ClosedGroupPoller(
} }
} }
} }
mutableState.value = StartedState(job = job)
job.invokeOnCompletion {
mutableState.value = IdleState
}
} }
fun stop() { fun stop() {
job?.cancel() Log.d(TAG, "Stopping closed group poller for ${closedGroupSessionId.hexString.take(4)}")
job = null (state.value as? StartedState)?.job?.cancel()
} }
private suspend fun poll(snode: Snode): Unit = supervisorScope { private suspend fun poll(snode: Snode): Unit = supervisorScope {
@ -236,6 +252,12 @@ class ClosedGroupPoller(
} }
} }
} }
// Update the state to indicate that we had at least one successful poll
val currentState = state.value as? StartedState
if (currentState != null && !currentState.hadAtLeastOneSuccessfulPoll) {
mutableState.value = currentState.copy(hadAtLeastOneSuccessfulPoll = true)
}
} }
private fun RetrieveMessageResponse.Message.toConfigMessage(): ConfigMessage { private fun RetrieveMessageResponse.Message.toConfigMessage(): ConfigMessage {