Merge pull request #496 from greenart7c3/claude/group-intents-by-type-m9ndb1

Group signing requests by type and payload in multi-event screens
This commit is contained in:
greenart7c3
2026-07-08 08:58:35 -03:00
committed by GitHub
4 changed files with 359 additions and 20 deletions
@@ -21,6 +21,7 @@ import androidx.compose.material3.TriStateCheckbox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -151,19 +152,57 @@ fun BunkerMultiEventHomeScreen(
Text(stringResource(R.string.select_deselect_all)) 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),
)
}
}
val expandedGroups = remember { mutableStateMapOf<RequestGroupKey, Boolean>() }
LazyColumn( LazyColumn(
Modifier.weight(1f), Modifier.weight(1f),
) { ) {
items(bunkerRequests, key = { it.request.id }) { bunkerRequest -> groups.forEach { (groupKey, groupItems) ->
BunkerRequestCard( val expanded = groups.size == 1 || (expandedGroups[groupKey] ?: false)
context = context, if (groups.size > 1) {
bunkerRequest = bunkerRequest, item(key = "group-header:${groupKey.type.name}:${groupKey.payload?.name ?: ""}:${groupKey.kind ?: ""}") {
checked = MultiEventScreenIntents.checkedStates[bunkerRequest.request.id] ?: true, val groupState = when {
onToggleChecked = { groupItems.all { MultiEventScreenIntents.checkedStates[it.request.id] ?: true } -> ToggleableState.On
val current = MultiEventScreenIntents.checkedStates[bunkerRequest.request.id] ?: true groupItems.none { MultiEventScreenIntents.checkedStates[it.request.id] ?: true } -> ToggleableState.Off
MultiEventScreenIntents.checkedStates[bunkerRequest.request.id] = !current else -> ToggleableState.Indeterminate
}, }
) RequestGroupHeader(
label = groupKey.toLabel(context),
count = groupItems.size,
state = groupState,
expanded = expanded,
onToggle = {
val newValue = groupState != ToggleableState.On
MultiEventScreenIntents.checkedStates.putAll(groupItems.associate { it.request.id to newValue })
},
onExpandToggle = {
expandedGroups[groupKey] = !(expandedGroups[groupKey] ?: false)
},
)
}
}
if (expanded) {
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
},
)
}
}
} }
} }
@@ -23,6 +23,7 @@ import androidx.compose.material3.TriStateCheckbox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -122,19 +123,52 @@ fun IntentMultiEventHomeScreen(
Text(stringResource(R.string.select_deselect_all)) Text(stringResource(R.string.select_deselect_all))
} }
val groups = remember(intents) {
groupRequests(intents) {
requestGroupKey(it.type, it.event?.kind, it.encryptedData, it.nip44v3Kind)
}
}
val expandedGroups = remember { mutableStateMapOf<RequestGroupKey, Boolean>() }
LazyColumn( LazyColumn(
Modifier.weight(1f), Modifier.weight(1f),
) { ) {
items(intents, key = { it.id }) { intent -> groups.forEach { (groupKey, groupIntents) ->
IntentRequestCard( val expanded = groups.size == 1 || (expandedGroups[groupKey] ?: false)
context = context, if (groups.size > 1) {
intent = intent, item(key = "group-header:${groupKey.type.name}:${groupKey.payload?.name ?: ""}:${groupKey.kind ?: ""}") {
checked = MultiEventScreenIntents.checkedStates[intent.id] ?: true, val groupState = when {
onToggleChecked = { groupIntents.all { MultiEventScreenIntents.checkedStates[it.id] ?: true } -> ToggleableState.On
val current = MultiEventScreenIntents.checkedStates[intent.id] ?: true groupIntents.none { MultiEventScreenIntents.checkedStates[it.id] ?: true } -> ToggleableState.Off
MultiEventScreenIntents.checkedStates[intent.id] = !current else -> ToggleableState.Indeterminate
}, }
) RequestGroupHeader(
label = groupKey.toLabel(context),
count = groupIntents.size,
state = groupState,
expanded = expanded,
onToggle = {
val newValue = groupState != ToggleableState.On
MultiEventScreenIntents.checkedStates.putAll(groupIntents.associate { it.id to newValue })
},
onExpandToggle = {
expandedGroups[groupKey] = !(expandedGroups[groupKey] ?: false)
},
)
}
}
if (expanded) {
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,148 @@
package com.greenart7c3.nostrsigner.ui.components
import android.content.Context
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TriStateCheckbox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.unit.dp
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,
expanded: Boolean,
onToggle: () -> Unit,
onExpandToggle: () -> Unit,
) {
val rotation by animateFloatAsState(
targetValue = if (expanded) 180f else 0f,
label = "group header chevron rotation",
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { onExpandToggle() },
) {
TriStateCheckbox(
state = state,
onClick = onToggle,
)
Text(
text = "$label ($count)",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
)
Icon(
imageVector = Icons.Default.ExpandMore,
contentDescription = null,
modifier = Modifier
.padding(end = 12.dp)
.rotate(rotation),
)
}
}
@@ -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 })
}
}