feat: complete AddMember KeyPackage fetch and resolve member display names

AddMember flow (was TODO, now functional):
- Account.fetchKeyPackageAndAddMember(): queries outbox relays for the
  target user's KeyPackageEvent (kind:30443) using client.fetchFirst(),
  decodes the base64 KeyPackage content, and calls addMarmotGroupMember()
  which publishes the commit then sends the Welcome gift wrap
- AddMemberScreen now calls the real implementation and navigates back
  on success

Member display names:
- MemberRow now uses LoadUser to resolve Nostr pubkeys to User profiles
  from LocalCache, showing bestDisplayName() instead of raw hex
- UserPicture composable shows profile pictures next to member names
- "(you)" indicator for the current user's entry

https://claude.ai/code/session_0194SxKfAU61PY92eqP4cCXM
This commit is contained in:
Claude
2026-04-05 21:43:51 +00:00
parent 3e14022e38
commit 8c2e498a08
4 changed files with 99 additions and 35 deletions
@@ -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.
@@ -1471,6 +1471,11 @@ class AccountViewModel(
fun marmotGroupMembers(nostrGroupId: String): List<com.vitorpamplona.amethyst.commons.marmot.GroupMemberInfo> = 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()
@@ -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
@@ -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,