fix: Marmot group add member flow (init, display, multi-select)
Three issues in the Marmot "new group → add members" flow: 1. "Marmot not initialized" error after creating a group. AccountCacheState silently swallowed any exception from constructing AndroidMlsGroupStateStore and set the store to null, which propagated to Account.marmotManager being null. createMarmotGroup then silently returned, leaving the UI in a half-created state where AddMemberScreen would always error out. Now we log the exception and fall back to an in-memory MlsGroupStateStore so the group operations at least work within the current session. 2. Hex pubkey shown under selected user's name. Replaced with the NIP-05 identifier when available, or the shortened npub otherwise, matching how ShowUserSuggestionList renders users. 3. Could only select one user at a time. AddMemberScreen now keeps a list of selected users, shows each with its own Remove button, and the Add action adds them sequentially, reporting per-user successes and failures. Failed users stay selected so the user can retry.
This commit is contained in:
+8
-2
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.marmot.AndroidMlsGroupStateStore
|
||||
import com.vitorpamplona.amethyst.model.marmot.InMemoryMlsGroupStateStore
|
||||
import com.vitorpamplona.amethyst.service.location.LocationState
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
@@ -100,8 +101,13 @@ class AccountCacheState(
|
||||
val mlsStore =
|
||||
try {
|
||||
AndroidMlsGroupStateStore(rootFilesDir())
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
Log.e(
|
||||
"AccountCacheState",
|
||||
"Failed to initialize AndroidMlsGroupStateStore, falling back to in-memory store (Marmot groups will not persist across restarts)",
|
||||
e,
|
||||
)
|
||||
InMemoryMlsGroupStateStore()
|
||||
}
|
||||
|
||||
return Account(
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.marmot
|
||||
|
||||
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* In-memory fallback implementation of [MlsGroupStateStore].
|
||||
*
|
||||
* Used only when [AndroidMlsGroupStateStore] cannot be initialized (e.g., when the
|
||||
* Android KeyStore is unavailable). State is lost on app restart, but this lets
|
||||
* Marmot group operations at least work within a single session instead of failing
|
||||
* with "Marmot not initialized".
|
||||
*/
|
||||
class InMemoryMlsGroupStateStore : MlsGroupStateStore {
|
||||
private val states = ConcurrentHashMap<String, ByteArray>()
|
||||
private val retainedEpochs = ConcurrentHashMap<String, List<ByteArray>>()
|
||||
|
||||
override suspend fun save(
|
||||
nostrGroupId: String,
|
||||
state: ByteArray,
|
||||
) {
|
||||
states[nostrGroupId] = state
|
||||
}
|
||||
|
||||
override suspend fun load(nostrGroupId: String): ByteArray? = states[nostrGroupId]
|
||||
|
||||
override suspend fun delete(nostrGroupId: String) {
|
||||
states.remove(nostrGroupId)
|
||||
retainedEpochs.remove(nostrGroupId)
|
||||
}
|
||||
|
||||
override suspend fun listGroups(): List<String> = states.keys.toList()
|
||||
|
||||
override suspend fun saveRetainedEpochs(
|
||||
nostrGroupId: String,
|
||||
retainedSecrets: List<ByteArray>,
|
||||
) {
|
||||
retainedEpochs[nostrGroupId] = retainedSecrets
|
||||
}
|
||||
|
||||
override suspend fun loadRetainedEpochs(nostrGroupId: String): List<ByteArray> = retainedEpochs[nostrGroupId] ?: emptyList()
|
||||
}
|
||||
+109
-39
@@ -28,13 +28,16 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -42,7 +45,10 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.ActionTopBar
|
||||
@@ -62,8 +68,9 @@ fun AddMemberScreen(
|
||||
nav: INav,
|
||||
) {
|
||||
var searchInput by remember { mutableStateOf("") }
|
||||
var selectedUser by remember { mutableStateOf<User?>(null) }
|
||||
val selectedUsers = remember { mutableStateListOf<User>() }
|
||||
var statusMessage by remember { mutableStateOf<String?>(null) }
|
||||
var isError by remember { mutableStateOf(false) }
|
||||
var isAdding by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
@@ -82,30 +89,62 @@ fun AddMemberScreen(
|
||||
postRes = com.vitorpamplona.amethyst.R.string.add,
|
||||
onCancel = { nav.popBack() },
|
||||
onPost = {
|
||||
val pubkey = selectedUser?.pubkeyHex
|
||||
if (pubkey == null) {
|
||||
statusMessage = "Error: No user selected"
|
||||
if (selectedUsers.isEmpty()) {
|
||||
statusMessage = "No users selected"
|
||||
isError = true
|
||||
return@ActionTopBar
|
||||
}
|
||||
isAdding = true
|
||||
statusMessage = "Fetching KeyPackage..."
|
||||
isError = false
|
||||
val usersToAdd = selectedUsers.toList()
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val result =
|
||||
accountViewModel.addMarmotGroupMember(nostrGroupId, pubkey)
|
||||
statusMessage = result
|
||||
if (result.startsWith("Success")) {
|
||||
nav.popBack()
|
||||
} else {
|
||||
isAdding = false
|
||||
var successCount = 0
|
||||
val failures = mutableListOf<Pair<User, String>>()
|
||||
for ((index, user) in usersToAdd.withIndex()) {
|
||||
statusMessage =
|
||||
"Adding ${user.toBestDisplayName()} (${index + 1}/${usersToAdd.size})..."
|
||||
isError = false
|
||||
try {
|
||||
val result =
|
||||
accountViewModel.addMarmotGroupMember(
|
||||
nostrGroupId,
|
||||
user.pubkeyHex,
|
||||
)
|
||||
if (result.startsWith("Success")) {
|
||||
successCount++
|
||||
} else {
|
||||
failures.add(user to result.removePrefix("Error: "))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
failures.add(user to (e.message ?: "unknown error"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
statusMessage = "Error: ${e.message}"
|
||||
}
|
||||
|
||||
if (failures.isEmpty()) {
|
||||
nav.popBack()
|
||||
} else {
|
||||
statusMessage =
|
||||
buildString {
|
||||
if (successCount > 0) {
|
||||
append("Added $successCount. ")
|
||||
}
|
||||
append("Failed: ")
|
||||
append(
|
||||
failures.joinToString("; ") { (user, reason) ->
|
||||
"${user.toBestDisplayName()} ($reason)"
|
||||
},
|
||||
)
|
||||
}
|
||||
isError = true
|
||||
// Keep failed users in the list for retry, drop successful ones
|
||||
val failedUsers = failures.map { it.first }.toSet()
|
||||
selectedUsers.clear()
|
||||
selectedUsers.addAll(failedUsers)
|
||||
isAdding = false
|
||||
}
|
||||
}
|
||||
},
|
||||
isActive = { !isAdding && selectedUser != null },
|
||||
isActive = { !isAdding && selectedUsers.isNotEmpty() },
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
@@ -116,17 +155,25 @@ fun AddMemberScreen(
|
||||
.consumeWindowInsets(padding)
|
||||
.imePadding(),
|
||||
) {
|
||||
// Selected user display
|
||||
if (selectedUser != null) {
|
||||
SelectedUserRow(
|
||||
user = selectedUser!!,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onClear = {
|
||||
selectedUser = null
|
||||
statusMessage = null
|
||||
},
|
||||
)
|
||||
// Selected users list
|
||||
if (selectedUsers.isNotEmpty()) {
|
||||
selectedUsers.toList().forEachIndexed { index, user ->
|
||||
SelectedUserRow(
|
||||
user = user,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
enabled = !isAdding,
|
||||
onClear = {
|
||||
selectedUsers.remove(user)
|
||||
statusMessage = null
|
||||
isError = false
|
||||
},
|
||||
)
|
||||
if (index < selectedUsers.lastIndex) {
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
// Search field
|
||||
@@ -134,7 +181,7 @@ fun AddMemberScreen(
|
||||
value = searchInput,
|
||||
onValueChange = { newValue ->
|
||||
searchInput = newValue
|
||||
statusMessage = null
|
||||
if (!isError) statusMessage = null
|
||||
if (newValue.length > 2) {
|
||||
userSuggestions.processCurrentWord(newValue)
|
||||
} else {
|
||||
@@ -148,7 +195,7 @@ fun AddMemberScreen(
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
singleLine = true,
|
||||
enabled = selectedUser == null && !isAdding,
|
||||
enabled = !isAdding,
|
||||
)
|
||||
|
||||
if (statusMessage != null) {
|
||||
@@ -157,7 +204,7 @@ fun AddMemberScreen(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color =
|
||||
if (statusMessage!!.startsWith("Error")) {
|
||||
if (isError) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
@@ -168,11 +215,13 @@ fun AddMemberScreen(
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// User suggestion list
|
||||
if (selectedUser == null && searchInput.length > 2) {
|
||||
if (!isAdding && searchInput.length > 2) {
|
||||
ShowUserSuggestionList(
|
||||
userSuggestions = userSuggestions,
|
||||
onSelect = { user ->
|
||||
selectedUser = user
|
||||
if (selectedUsers.none { it.pubkeyHex == user.pubkeyHex }) {
|
||||
selectedUsers.add(user)
|
||||
}
|
||||
searchInput = ""
|
||||
userSuggestions.reset()
|
||||
},
|
||||
@@ -197,6 +246,7 @@ private fun SelectedUserRow(
|
||||
user: User,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
enabled: Boolean,
|
||||
onClear: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
@@ -219,15 +269,35 @@ private fun SelectedUserRow(
|
||||
text = user.toBestDisplayName(),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = "${user.pubkeyHex.take(16)}...",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
UserSecondaryLine(user)
|
||||
}
|
||||
androidx.compose.material3.TextButton(onClick = onClear) {
|
||||
Text("Clear")
|
||||
TextButton(onClick = onClear, enabled = enabled) {
|
||||
Text("Remove")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserSecondaryLine(user: User) {
|
||||
val nip05StateMetadata by user.nip05State().flow.collectAsStateWithLifecycle()
|
||||
|
||||
val text =
|
||||
when (val state = nip05StateMetadata) {
|
||||
is Nip05State.Exists -> {
|
||||
val name = state.nip05.name
|
||||
if (name == "_") state.nip05.domain else "$name@${state.nip05.domain}"
|
||||
}
|
||||
else -> user.pubkeyDisplayHex()
|
||||
}
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user