diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 7600664a28..8e1ff8ffd7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1731,6 +1731,60 @@ class Account( client.publish(outbound.signedEvent, groupRelays) } + /** + * Fetch a user's KeyPackage from relays and add them to a Marmot group. + * Returns a status message describing the outcome. + */ + @OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class) + suspend fun fetchKeyPackageAndAddMember( + nostrGroupId: HexKey, + memberPubKey: HexKey, + ): String { + val manager = marmotManager ?: return "Error: Marmot not initialized" + if (!isWriteable()) return "Error: Account is read-only" + + // Build filter for the member's KeyPackages + val filter = manager.subscriptionManager.keyPackageFilter(memberPubKey) + val relays = outboxRelays.flow.value + + // Query across outbox relays + val filterMap = relays.associateWith { listOf(filter) } + + val event = + client.fetchFirst( + filters = filterMap, + ) + + if (event == null) { + return "Error: No KeyPackage found for this user. They may not have published one yet." + } + + if (event !is com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent) { + return "Error: Unexpected event type received" + } + + val keyPackageBase64 = event.keyPackageBase64() + if (keyPackageBase64.isBlank()) { + return "Error: KeyPackage event has empty content" + } + + val keyPackageBytes = + kotlin.io.encoding.Base64 + .decode(keyPackageBase64) + val keyPackageEventId = event.id + val groupRelays = relays.toList() + + addMarmotGroupMember( + nostrGroupId = nostrGroupId, + memberPubKey = memberPubKey, + keyPackageBytes = keyPackageBytes, + keyPackageEventId = keyPackageEventId, + groupRelays = groupRelays, + ) + + return "Success: Member added to group" + } + /** * Add a member to a Marmot MLS group. * Publishes the commit GroupEvent, then sends the Welcome gift wrap. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 791a9fa4ac..2506761bb8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1471,6 +1471,11 @@ class AccountViewModel( fun marmotGroupMembers(nostrGroupId: String): List = account.marmotManager?.memberPubkeys(nostrGroupId) ?: emptyList() + suspend fun addMarmotGroupMember( + nostrGroupId: String, + memberPubKey: String, + ): String = account.fetchKeyPackageAndAddMember(nostrGroupId, memberPubKey) + override fun onCleared() { Log.d("AccountViewModel", "onCleared") callController?.cleanup() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/AddMemberScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/AddMemberScreen.kt index b7fccb22d9..321a7b9480 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/AddMemberScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/AddMemberScreen.kt @@ -65,21 +65,23 @@ fun AddMemberScreen( onCancel = { nav.popBack() }, onPost = { isAdding = true - statusMessage = "Looking up KeyPackage..." + val pubkey = resolvePubkey(memberInput) + if (pubkey == null) { + statusMessage = "Error: Invalid public key format" + return@ActionTopBar + } + isAdding = true + statusMessage = "Fetching KeyPackage for ${pubkey.take(8)}..." scope.launch(Dispatchers.IO) { try { - val pubkey = resolvePubkey(memberInput) - if (pubkey == null) { - statusMessage = "Error: Invalid public key format" + val result = + accountViewModel.addMarmotGroupMember(nostrGroupId, pubkey) + statusMessage = result + if (result.startsWith("Success")) { + nav.popBack() + } else { isAdding = false - return@launch } - statusMessage = "Fetching KeyPackage for ${pubkey.take(8)}..." - // TODO: Fetch KeyPackage from relays and call addMarmotGroupMember - statusMessage = - "Error: KeyPackage fetch not yet implemented. " + - "The member's KeyPackage (kind:30443) must be fetched from relays." - isAdding = false } catch (e: Exception) { statusMessage = "Error: ${e.message}" isAdding = false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt index 1ce5428144..45876ad4d1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt @@ -27,14 +27,12 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ExitToApp import androidx.compose.material.icons.filled.GroupAdd -import androidx.compose.material.icons.filled.Person import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider @@ -61,7 +59,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.marmot.GroupMemberInfo import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -170,9 +170,8 @@ fun MarmotGroupInfoScreen( MemberRow( member = member, isMe = member.pubkey == myPubkey, - onClick = { - nav.nav(Route.Profile(member.pubkey)) - }, + accountViewModel = accountViewModel, + nav = nav, ) HorizontalDivider() } @@ -223,36 +222,40 @@ fun MarmotGroupInfoScreen( fun MemberRow( member: GroupMemberInfo, isMe: Boolean, - onClick: () -> Unit, + accountViewModel: AccountViewModel, + nav: INav, ) { Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick) + .clickable { nav.nav(Route.Profile(member.pubkey)) } .padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Icon( - imageVector = Icons.Default.Person, - contentDescription = null, - modifier = Modifier.size(36.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, + UserPicture( + userHex = member.pubkey, + size = 36.dp, + accountViewModel = accountViewModel, + nav = nav, ) Column(modifier = Modifier.weight(1f)) { - Text( - text = - if (isMe) { - "${member.pubkey.take(16)}... (you)" - } else { - "${member.pubkey.take(16)}..." - }, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - fontWeight = if (isMe) FontWeight.Bold else FontWeight.Normal, - ) + LoadUser(baseUserHex = member.pubkey, accountViewModel = accountViewModel) { user -> + val displayName = user?.toBestDisplayName() ?: "${member.pubkey.take(16)}..." + Text( + text = + if (isMe) { + "$displayName (you)" + } else { + displayName + }, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = if (isMe) FontWeight.Bold else FontWeight.Normal, + ) + } Text( text = "Leaf #${member.leafIndex}", style = MaterialTheme.typography.bodySmall,