Group multi-request approval lists by type and kind

When several intents or bunker requests are shown at once, the list is
now sectioned by request type and kind instead of arrival order:

- sign_event requests group by event kind
- encrypt/decrypt requests separate by payload shape (event, tag array,
  clear text, private zap) and, for event payloads, by embedded kind
- NIP-44 v3 requests group by their explicit kind parameter

Each section header shows a localized label with a count and a
tri-state checkbox to select or deselect the whole group. Grouping is
presentation-only; approve/discard behavior is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TioEdhZRp9W78DbsHzEypZ
This commit is contained in:
Claude
2026-07-07 19:30:39 +00:00
parent b3d420819e
commit 6caa68f6e6
4 changed files with 319 additions and 20 deletions
@@ -151,19 +151,49 @@ fun BunkerMultiEventHomeScreen(
Text(stringResource(R.string.select_deselect_all))
}
val groups = remember(bunkerRequests) {
groupRequests(bunkerRequests) {
requestGroupKey(
type = BunkerRequestUtils.getTypeFromBunker(it.request),
eventKind = (it.request as? BunkerRequestSign)?.event?.kind,
encryptedData = it.encryptedData,
nip44v3Kind = BunkerRequestUtils.getNip44v3Kind(it.request),
)
}
}
LazyColumn(
Modifier.weight(1f),
) {
items(bunkerRequests, key = { it.request.id }) { bunkerRequest ->
BunkerRequestCard(
context = context,
bunkerRequest = bunkerRequest,
checked = MultiEventScreenIntents.checkedStates[bunkerRequest.request.id] ?: true,
onToggleChecked = {
val current = MultiEventScreenIntents.checkedStates[bunkerRequest.request.id] ?: true
MultiEventScreenIntents.checkedStates[bunkerRequest.request.id] = !current
},
)
groups.forEach { (groupKey, groupItems) ->
if (groups.size > 1) {
item(key = "group-header:${groupKey.type.name}:${groupKey.payload?.name ?: ""}:${groupKey.kind ?: ""}") {
val groupState = when {
groupItems.all { MultiEventScreenIntents.checkedStates[it.request.id] ?: true } -> ToggleableState.On
groupItems.none { MultiEventScreenIntents.checkedStates[it.request.id] ?: true } -> ToggleableState.Off
else -> ToggleableState.Indeterminate
}
RequestGroupHeader(
label = groupKey.toLabel(context),
count = groupItems.size,
state = groupState,
onToggle = {
val newValue = groupState != ToggleableState.On
MultiEventScreenIntents.checkedStates.putAll(groupItems.associate { it.request.id to newValue })
},
)
}
}
items(groupItems, key = { it.request.id }) { bunkerRequest ->
BunkerRequestCard(
context = context,
bunkerRequest = bunkerRequest,
checked = MultiEventScreenIntents.checkedStates[bunkerRequest.request.id] ?: true,
onToggleChecked = {
val current = MultiEventScreenIntents.checkedStates[bunkerRequest.request.id] ?: true
MultiEventScreenIntents.checkedStates[bunkerRequest.request.id] = !current
},
)
}
}
}
@@ -122,19 +122,44 @@ fun IntentMultiEventHomeScreen(
Text(stringResource(R.string.select_deselect_all))
}
val groups = remember(intents) {
groupRequests(intents) {
requestGroupKey(it.type, it.event?.kind, it.encryptedData, it.nip44v3Kind)
}
}
LazyColumn(
Modifier.weight(1f),
) {
items(intents, key = { it.id }) { intent ->
IntentRequestCard(
context = context,
intent = intent,
checked = MultiEventScreenIntents.checkedStates[intent.id] ?: true,
onToggleChecked = {
val current = MultiEventScreenIntents.checkedStates[intent.id] ?: true
MultiEventScreenIntents.checkedStates[intent.id] = !current
},
)
groups.forEach { (groupKey, groupIntents) ->
if (groups.size > 1) {
item(key = "group-header:${groupKey.type.name}:${groupKey.payload?.name ?: ""}:${groupKey.kind ?: ""}") {
val groupState = when {
groupIntents.all { MultiEventScreenIntents.checkedStates[it.id] ?: true } -> ToggleableState.On
groupIntents.none { MultiEventScreenIntents.checkedStates[it.id] ?: true } -> ToggleableState.Off
else -> ToggleableState.Indeterminate
}
RequestGroupHeader(
label = groupKey.toLabel(context),
count = groupIntents.size,
state = groupState,
onToggle = {
val newValue = groupState != ToggleableState.On
MultiEventScreenIntents.checkedStates.putAll(groupIntents.associate { it.id to newValue })
},
)
}
}
items(groupIntents, key = { it.id }) { intent ->
IntentRequestCard(
context = context,
intent = intent,
checked = MultiEventScreenIntents.checkedStates[intent.id] ?: true,
onToggleChecked = {
val current = MultiEventScreenIntents.checkedStates[intent.id] ?: true
MultiEventScreenIntents.checkedStates[intent.id] = !current
},
)
}
}
}
@@ -0,0 +1,126 @@
package com.greenart7c3.nostrsigner.ui.components
import android.content.Context
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TriStateCheckbox
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.state.ToggleableState
import com.greenart7c3.nostrsigner.R
import com.greenart7c3.nostrsigner.models.ClearTextEncryptedDataKind
import com.greenart7c3.nostrsigner.models.EncryptedDataKind
import com.greenart7c3.nostrsigner.models.EventEncryptedDataKind
import com.greenart7c3.nostrsigner.models.Permission
import com.greenart7c3.nostrsigner.models.PrivateZapEncryptedDataKind
import com.greenart7c3.nostrsigner.models.SignerType
import com.greenart7c3.nostrsigner.models.TagArrayEncryptedDataKind
import com.greenart7c3.nostrsigner.models.encryptDecryptSignerTypes
enum class RequestPayloadShape {
EVENT,
TAG_ARRAY,
CLEAR_TEXT,
PRIVATE_ZAP,
}
data class RequestGroupKey(
val type: SignerType,
val kind: Int?,
val payload: RequestPayloadShape?,
)
fun requestGroupKey(
type: SignerType,
eventKind: Int?,
encryptedData: EncryptedDataKind?,
nip44v3Kind: Int?,
): RequestGroupKey = when {
type == SignerType.SIGN_EVENT -> RequestGroupKey(type, eventKind, null)
type == SignerType.NIP44_V3_ENCRYPT || type == SignerType.NIP44_V3_DECRYPT -> RequestGroupKey(type, nip44v3Kind, null)
type in encryptDecryptSignerTypes -> when (encryptedData) {
is EventEncryptedDataKind -> RequestGroupKey(type, encryptedData.event.kind, RequestPayloadShape.EVENT)
is TagArrayEncryptedDataKind -> RequestGroupKey(type, null, RequestPayloadShape.TAG_ARRAY)
is ClearTextEncryptedDataKind -> RequestGroupKey(type, null, RequestPayloadShape.CLEAR_TEXT)
is PrivateZapEncryptedDataKind -> RequestGroupKey(type, null, RequestPayloadShape.PRIVATE_ZAP)
else -> RequestGroupKey(type, null, null)
}
else -> RequestGroupKey(type, null, null)
}
fun <T> groupRequests(
items: List<T>,
keyOf: (T) -> RequestGroupKey,
): List<Pair<RequestGroupKey, List<T>>> = items.groupBy(keyOf).toList()
.sortedWith(
compareBy(
{ it.first.type.ordinal },
{ it.first.payload?.ordinal ?: -1 },
{ it.first.kind ?: -1 },
),
)
fun RequestGroupKey.toLabel(context: Context): String {
val isEncrypt = type.name.contains("ENCRYPT")
val nip = type.name.split("_").first()
return when {
type == SignerType.CONNECT -> context.getString(R.string.connect)
type == SignerType.SIGN_EVENT -> Permission("sign_event", kind).toLocalizedString(context)
payload == RequestPayloadShape.EVENT -> {
val kindLabel = Permission("sign_event", kind).toLocalizedString(context)
if (isEncrypt) {
context.getString(R.string.encrypt_with, kindLabel, nip)
} else {
context.getString(R.string.read_from_encrypted_content, kindLabel, nip)
}
}
payload == RequestPayloadShape.TAG_ARRAY -> if (isEncrypt) {
context.getString(R.string.encrypt_this_list_of_tags_with, nip)
} else {
context.getString(R.string.read_this_list_of_tags_from_encrypted_content, nip)
}
payload == RequestPayloadShape.CLEAR_TEXT -> if (isEncrypt) {
context.getString(R.string.encrypt_this_text_with, nip)
} else {
context.getString(R.string.read_this_text_from_encrypted_content, nip)
}
payload == RequestPayloadShape.PRIVATE_ZAP -> context.getString(R.string.decrypt_zap_event).replaceFirstChar { it.uppercase() }
(type == SignerType.NIP44_V3_ENCRYPT || type == SignerType.NIP44_V3_DECRYPT) && kind != null -> {
val kindLabel = Permission("sign_event", kind).toLocalizedString(context)
if (isEncrypt) {
context.getString(R.string.encrypt_with, kindLabel, nip)
} else {
context.getString(R.string.read_from_encrypted_content, kindLabel, nip)
}
}
else -> Permission(type.name.lowercase(), null).toLocalizedString(context)
}
}
@Composable
fun RequestGroupHeader(
label: String,
count: Int,
state: ToggleableState,
onToggle: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { onToggle() },
) {
TriStateCheckbox(
state = state,
onClick = onToggle,
)
Text(
text = "$label ($count)",
style = MaterialTheme.typography.titleSmall,
)
}
}
@@ -0,0 +1,118 @@
package com.greenart7c3.nostrsigner.ui.components
import com.greenart7c3.nostrsigner.models.ClearTextEncryptedDataKind
import com.greenart7c3.nostrsigner.models.EventEncryptedDataKind
import com.greenart7c3.nostrsigner.models.PrivateZapEncryptedDataKind
import com.greenart7c3.nostrsigner.models.SignerType
import com.greenart7c3.nostrsigner.models.TagArrayEncryptedDataKind
import com.greenart7c3.nostrsigner.service.model.AmberEvent
import org.junit.Assert.assertEquals
import org.junit.Test
class RequestGroupUtilsTest {
private fun amberEvent(kind: Int) = AmberEvent("id", "pubkey", 0L, kind, arrayOf(), "", "")
private fun eventData(kind: Int) = EventEncryptedDataKind(amberEvent(kind), null, "result")
@Test
fun `sign event groups by event kind`() {
val key1 = requestGroupKey(SignerType.SIGN_EVENT, 1, null, null)
val key1b = requestGroupKey(SignerType.SIGN_EVENT, 1, null, null)
val key22242 = requestGroupKey(SignerType.SIGN_EVENT, 22242, null, null)
assertEquals(key1, key1b)
assertEquals(1, key1.kind)
assertEquals(22242, key22242.kind)
assertEquals(SignerType.SIGN_EVENT, key1.type)
assertEquals(null, key1.payload)
}
@Test
fun `encrypt decrypt separates by payload shape`() {
val eventKey = requestGroupKey(SignerType.NIP44_DECRYPT, null, eventData(4), null)
val tagKey = requestGroupKey(SignerType.NIP44_DECRYPT, null, TagArrayEncryptedDataKind(arrayOf(), "r"), null)
val textKey = requestGroupKey(SignerType.NIP44_DECRYPT, null, ClearTextEncryptedDataKind("t", "r"), null)
val zapKey = requestGroupKey(SignerType.DECRYPT_ZAP_EVENT, null, PrivateZapEncryptedDataKind("r"), null)
assertEquals(RequestPayloadShape.EVENT, eventKey.payload)
assertEquals(4, eventKey.kind)
assertEquals(RequestPayloadShape.TAG_ARRAY, tagKey.payload)
assertEquals(null, tagKey.kind)
assertEquals(RequestPayloadShape.CLEAR_TEXT, textKey.payload)
assertEquals(RequestPayloadShape.PRIVATE_ZAP, zapKey.payload)
}
@Test
fun `encrypt decrypt event payloads separate by embedded event kind`() {
val kind1 = requestGroupKey(SignerType.NIP44_ENCRYPT, null, eventData(1), null)
val kind1b = requestGroupKey(SignerType.NIP44_ENCRYPT, null, eventData(1), null)
val kind7 = requestGroupKey(SignerType.NIP44_ENCRYPT, null, eventData(7), null)
assertEquals(kind1, kind1b)
assertEquals(1, kind1.kind)
assertEquals(7, kind7.kind)
}
@Test
fun `nip44 v3 groups by explicit kind`() {
val enc9 = requestGroupKey(SignerType.NIP44_V3_ENCRYPT, null, null, 9)
val dec9 = requestGroupKey(SignerType.NIP44_V3_DECRYPT, null, null, 9)
val decNull = requestGroupKey(SignerType.NIP44_V3_DECRYPT, null, null, null)
assertEquals(9, enc9.kind)
assertEquals(9, dec9.kind)
assertEquals(null, decNull.kind)
assertEquals(null, enc9.payload)
}
@Test
fun `other types group by type alone`() {
val connect = requestGroupKey(SignerType.CONNECT, null, null, null)
val pubKey = requestGroupKey(SignerType.GET_PUBLIC_KEY, null, null, null)
assertEquals(RequestGroupKey(SignerType.CONNECT, null, null), connect)
assertEquals(RequestGroupKey(SignerType.GET_PUBLIC_KEY, null, null), pubKey)
}
@Test
fun `groupRequests sorts by type ordinal then payload then kind`() {
val items = listOf(
RequestGroupKey(SignerType.SIGN_EVENT, 22242, null),
RequestGroupKey(SignerType.NIP44_DECRYPT, null, RequestPayloadShape.CLEAR_TEXT),
RequestGroupKey(SignerType.CONNECT, null, null),
RequestGroupKey(SignerType.SIGN_EVENT, 1, null),
RequestGroupKey(SignerType.NIP44_DECRYPT, 4, RequestPayloadShape.EVENT),
)
val groups = groupRequests(items) { it }
assertEquals(
listOf(
RequestGroupKey(SignerType.CONNECT, null, null),
RequestGroupKey(SignerType.SIGN_EVENT, 1, null),
RequestGroupKey(SignerType.SIGN_EVENT, 22242, null),
RequestGroupKey(SignerType.NIP44_DECRYPT, 4, RequestPayloadShape.EVENT),
RequestGroupKey(SignerType.NIP44_DECRYPT, null, RequestPayloadShape.CLEAR_TEXT),
),
groups.map { it.first },
)
}
@Test
fun `groupRequests preserves input order within a group`() {
data class Item(val id: String, val kind: Int)
val items = listOf(
Item("a", 1),
Item("b", 7),
Item("c", 1),
Item("d", 1),
)
val groups = groupRequests(items) { RequestGroupKey(SignerType.SIGN_EVENT, it.kind, null) }
assertEquals(listOf("a", "c", "d"), groups.first { it.first.kind == 1 }.second.map { it.id })
assertEquals(listOf("b"), groups.first { it.first.kind == 7 }.second.map { it.id })
}
}