Merge pull request #498 from greenart7c3/claude/grouped-events-ui-options-9xgkdx

Group request options by type with per-group scope settings
This commit is contained in:
greenart7c3
2026-07-15 15:54:17 -03:00
committed by GitHub
6 changed files with 270 additions and 116 deletions
@@ -373,25 +373,14 @@ fun MainScreen(
content = {
val scrollState = rememberScrollState()
val modifier = if (intents.isEmpty() || packageName == null || destinationRoute != Route.IncomingRequest.route) {
Modifier
.fillMaxSize()
.padding(padding)
.verticalScrollbar(scrollState)
.verticalScroll(scrollState)
.padding(horizontal = verticalPadding)
.padding(top = verticalPadding * 1.5f)
} else {
Modifier
.fillMaxSize()
.padding(padding)
.verticalScrollbar(scrollState)
.verticalScroll(scrollState)
.padding(horizontal = verticalPadding)
}
IncomingRequestScreen(
horizontalPadding = verticalPadding,
modifier = modifier,
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScrollbar(scrollState)
.verticalScroll(scrollState)
.padding(horizontal = verticalPadding),
scaffoldPadding = padding,
intents = intents,
bunkerRequests = bunkerRequests,
@@ -78,11 +78,11 @@ fun BunkerMultiEventHomeScreen(
onLoading: (Boolean) -> Unit,
) {
val context = LocalContext.current
val hasRelayAuthEvents = bunkerRequests.any { it.request is BunkerRequestSign && it.request.event.kind == 22242 }
var localAccount by remember { mutableStateOf("") }
val key = bunkerRequests.first().localKey
var rememberType by remember { mutableStateOf(RememberType.NEVER) }
var relayAuthScope by remember { mutableStateOf(RelayAuthScope.SPECIFIC) }
val groupRememberTypes = remember { mutableStateMapOf<RequestGroupKey, RememberType>() }
val groupRelayAuthScopes = remember { mutableStateMapOf<RequestGroupKey, RelayAuthScope>() }
val groupDecryptScopes = remember { mutableStateMapOf<RequestGroupKey, DecryptTypeScope>() }
var appName by remember { mutableStateOf(ApplicationNameCache["$localAccount-$key"] ?: key.toShortenHex()) }
var appIcon by remember { mutableStateOf(bunkerRequests.first().clientMetadata?.image ?: "") }
@@ -190,6 +190,19 @@ fun BunkerMultiEventHomeScreen(
)
}
}
if (groupKey.hasGroupOptions()) {
item(key = "group-options:${groupKey.type.name}:${groupKey.payload?.name ?: ""}:${groupKey.kind ?: ""}") {
RequestGroupOptions(
groupKey = groupKey,
rememberType = groupRememberTypes[groupKey] ?: RememberType.NEVER,
onRememberTypeChanged = { groupRememberTypes[groupKey] = it },
relayAuthScope = groupRelayAuthScopes[groupKey] ?: RelayAuthScope.SPECIFIC,
onRelayAuthScopeChanged = { groupRelayAuthScopes[groupKey] = it },
decryptTypeScope = groupDecryptScopes[groupKey] ?: defaultDecryptTypeScope(groupKey.type),
onDecryptTypeScopeChanged = { groupDecryptScopes[groupKey] = it },
)
}
}
if (expanded) {
items(groupItems, key = { it.request.id }) { bunkerRequest ->
BunkerRequestCard(
@@ -206,41 +219,6 @@ fun BunkerMultiEventHomeScreen(
}
}
if (hasRelayAuthEvents) {
LabeledBorderBox(
label = stringResource(R.string.relay_auth_scope),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 8.dp),
) {
AmberToggles(
selected = relayAuthScope,
options = listOf(RelayAuthScope.SPECIFIC, RelayAuthScope.ALL),
onSelected = { relayAuthScope = it },
label = {
stringResource(
when (it) {
RelayAuthScope.SPECIFIC -> R.string.for_this_relay_only
RelayAuthScope.ALL -> R.string.for_all_relays
},
)
},
)
}
}
RememberMyChoice(
alwaysShow = true,
shouldRunAcceptOrReject = null,
onAccept = {},
onReject = {},
onChanged = {
rememberType = it
MultiEventScreenIntents.rememberType = it
},
packageName = packageName,
)
Row(
Modifier
.fillMaxWidth()
@@ -297,10 +275,24 @@ fun BunkerMultiEventHomeScreen(
)
val isChecked = MultiEventScreenIntents.checkedStates[request.request.id] ?: true
val requestType = BunkerRequestUtils.getTypeFromBunker(request.request)
val groupKey = requestGroupKey(
type = requestType,
eventKind = (request.request as? BunkerRequestSign)?.event?.kind,
encryptedData = request.encryptedData,
nip44v3Kind = BunkerRequestUtils.getNip44v3Kind(request.request),
)
val rememberType = groupRememberTypes[groupKey] ?: RememberType.NEVER
if (rememberType != RememberType.NEVER && isChecked) {
val rejectKind = if (request.request is BunkerRequestSign) request.request.event.kind else null
val decryptTypeScope = groupDecryptScopes[groupKey] ?: defaultDecryptTypeScope(requestType)
val rejectKind = when {
request.request is BunkerRequestSign -> request.request.event.kind
requestType == SignerType.NIP44_V3_ENCRYPT || requestType == SignerType.NIP44_V3_DECRYPT ->
if (decryptTypeScope == DecryptTypeScope.SPECIFIC) BunkerRequestUtils.getNip44v3Kind(request.request) else null
else -> null
}
val rejectRelay = if (request.request is BunkerRequestSign && request.request.event.kind == 22242) {
if (relayAuthScope == RelayAuthScope.ALL) {
if ((groupRelayAuthScopes[groupKey] ?: RelayAuthScope.SPECIFIC) == RelayAuthScope.ALL) {
"*"
} else {
RelayUrlUtils.extractHostAndPort(AmberEvent.relay(request.request.event))
@@ -311,13 +303,14 @@ fun BunkerMultiEventHomeScreen(
AmberUtils.acceptOrRejectPermission(
application,
localKey,
BunkerRequestUtils.getTypeFromBunker(request.request),
requestType,
rejectKind,
false,
rememberType,
thisAccount,
relay = rejectRelay,
encryptedData = request.encryptedData,
decryptTypeScope = decryptTypeScope,
)
}
@@ -389,13 +382,21 @@ fun BunkerMultiEventHomeScreen(
)
val isChecked = MultiEventScreenIntents.checkedStates[request.request.id] ?: true
val requestType = BunkerRequestUtils.getTypeFromBunker(request.request)
val groupKey = requestGroupKey(
type = requestType,
eventKind = (request.request as? BunkerRequestSign)?.event?.kind,
encryptedData = request.encryptedData,
nip44v3Kind = BunkerRequestUtils.getNip44v3Kind(request.request),
)
val rememberType = groupRememberTypes[groupKey] ?: RememberType.NEVER
if (request.request is BunkerRequestSign) {
val localEvent = request.signedEvent!!
if (rememberType != RememberType.NEVER && isChecked) {
val signRelay = if (localEvent.kind == 22242) {
if (relayAuthScope == RelayAuthScope.ALL) {
if ((groupRelayAuthScopes[groupKey] ?: RelayAuthScope.SPECIFIC) == RelayAuthScope.ALL) {
"*"
} else {
RelayUrlUtils.extractHostAndPort(AmberEvent.relay(localEvent))
@@ -497,17 +498,24 @@ fun BunkerMultiEventHomeScreen(
}
}
} else {
val type = BunkerRequestUtils.getTypeFromBunker(request.request)
val type = requestType
if (rememberType != RememberType.NEVER && isChecked) {
val decryptTypeScope = groupDecryptScopes[groupKey] ?: defaultDecryptTypeScope(type)
val permissionKind = if (type == SignerType.NIP44_V3_ENCRYPT || type == SignerType.NIP44_V3_DECRYPT) {
if (decryptTypeScope == DecryptTypeScope.SPECIFIC) BunkerRequestUtils.getNip44v3Kind(request.request) else null
} else {
null
}
AmberUtils.acceptOrRejectPermission(
application,
localKey,
type,
null,
permissionKind,
true,
rememberType,
thisAccount,
encryptedData = request.encryptedData,
decryptTypeScope = decryptTypeScope,
)
}
@@ -75,9 +75,9 @@ fun IntentMultiEventHomeScreen(
onLoading: (Boolean) -> Unit,
) {
val context = LocalContext.current
val hasRelayAuthEvents = intents.any { it.type == SignerType.SIGN_EVENT && it.event?.kind == 22242 }
var rememberType by remember { mutableStateOf(RememberType.NEVER) }
var relayAuthScope by remember { mutableStateOf(RelayAuthScope.SPECIFIC) }
val groupRememberTypes = remember { mutableStateMapOf<RequestGroupKey, RememberType>() }
val groupRelayAuthScopes = remember { mutableStateMapOf<RequestGroupKey, RelayAuthScope>() }
val groupDecryptScopes = remember { mutableStateMapOf<RequestGroupKey, DecryptTypeScope>() }
LaunchedEffect(Unit) {
MultiEventScreenIntents.checkedStates.clear()
@@ -156,6 +156,19 @@ fun IntentMultiEventHomeScreen(
)
}
}
if (groupKey.hasGroupOptions()) {
item(key = "group-options:${groupKey.type.name}:${groupKey.payload?.name ?: ""}:${groupKey.kind ?: ""}") {
RequestGroupOptions(
groupKey = groupKey,
rememberType = groupRememberTypes[groupKey] ?: RememberType.NEVER,
onRememberTypeChanged = { groupRememberTypes[groupKey] = it },
relayAuthScope = groupRelayAuthScopes[groupKey] ?: RelayAuthScope.SPECIFIC,
onRelayAuthScopeChanged = { groupRelayAuthScopes[groupKey] = it },
decryptTypeScope = groupDecryptScopes[groupKey] ?: defaultDecryptTypeScope(groupKey.type),
onDecryptTypeScopeChanged = { groupDecryptScopes[groupKey] = it },
)
}
}
if (expanded) {
items(groupIntents, key = { it.id }) { intent ->
IntentRequestCard(
@@ -172,41 +185,6 @@ fun IntentMultiEventHomeScreen(
}
}
if (hasRelayAuthEvents) {
LabeledBorderBox(
label = stringResource(R.string.relay_auth_scope),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 8.dp),
) {
AmberToggles(
selected = relayAuthScope,
options = listOf(RelayAuthScope.SPECIFIC, RelayAuthScope.ALL),
onSelected = { relayAuthScope = it },
label = {
stringResource(
when (it) {
RelayAuthScope.SPECIFIC -> R.string.for_this_relay_only
RelayAuthScope.ALL -> R.string.for_all_relays
},
)
},
)
}
}
RememberMyChoice(
alwaysShow = true,
shouldRunAcceptOrReject = null,
onAccept = {},
onReject = {},
onChanged = {
rememberType = it
MultiEventScreenIntents.rememberType = it
},
packageName = packageName,
)
Row(
Modifier
.fillMaxWidth()
@@ -260,10 +238,18 @@ fun IntentMultiEventHomeScreen(
var permissionsChanged = false
for (intentData in accountIntents) {
val isChecked = MultiEventScreenIntents.checkedStates[intentData.id] ?: true
val groupKey = requestGroupKey(intentData.type, intentData.event?.kind, intentData.encryptedData, intentData.nip44v3Kind)
val rememberType = groupRememberTypes[groupKey] ?: RememberType.NEVER
if (rememberType != RememberType.NEVER && isChecked) {
val rejectKind = if (intentData.type == SignerType.SIGN_EVENT) intentData.event?.kind else null
val decryptTypeScope = groupDecryptScopes[groupKey] ?: defaultDecryptTypeScope(intentData.type)
val rejectKind = when {
intentData.type == SignerType.SIGN_EVENT -> intentData.event?.kind
intentData.type == SignerType.NIP44_V3_ENCRYPT || intentData.type == SignerType.NIP44_V3_DECRYPT ->
if (decryptTypeScope == DecryptTypeScope.SPECIFIC) intentData.nip44v3Kind else null
else -> null
}
val rejectRelay = if (intentData.type == SignerType.SIGN_EVENT && intentData.event?.kind == 22242) {
if (relayAuthScope == RelayAuthScope.ALL) {
if ((groupRelayAuthScopes[groupKey] ?: RelayAuthScope.SPECIFIC) == RelayAuthScope.ALL) {
"*"
} else {
RelayUrlUtils.extractHostAndPort(AmberEvent.relay(intentData.event))
@@ -280,6 +266,7 @@ fun IntentMultiEventHomeScreen(
rememberType,
relay = rejectRelay,
encryptedData = intentData.encryptedData,
decryptTypeScope = decryptTypeScope,
)
permissionsChanged = true
}
@@ -359,13 +346,15 @@ fun IntentMultiEventHomeScreen(
for (intentData in accountIntents) {
val isChecked = MultiEventScreenIntents.checkedStates[intentData.id] ?: true
val type = intentData.type
val groupKey = requestGroupKey(type, intentData.event?.kind, intentData.encryptedData, intentData.nip44v3Kind)
val rememberType = groupRememberTypes[groupKey] ?: RememberType.NEVER
if (type == SignerType.SIGN_EVENT) {
val localEvent = intentData.event!!
if (rememberType != RememberType.NEVER && isChecked) {
val signRelay = if (localEvent.kind == 22242) {
if (relayAuthScope == RelayAuthScope.ALL) {
if ((groupRelayAuthScopes[groupKey] ?: RelayAuthScope.SPECIFIC) == RelayAuthScope.ALL) {
"*"
} else {
RelayUrlUtils.extractHostAndPort(AmberEvent.relay(localEvent))
@@ -420,14 +409,21 @@ fun IntentMultiEventHomeScreen(
}
} else {
if (rememberType != RememberType.NEVER && isChecked) {
val decryptTypeScope = groupDecryptScopes[groupKey] ?: defaultDecryptTypeScope(type)
val permissionKind = if (type == SignerType.NIP44_V3_ENCRYPT || type == SignerType.NIP44_V3_DECRYPT) {
if (decryptTypeScope == DecryptTypeScope.SPECIFIC) intentData.nip44v3Kind else null
} else {
null
}
AmberUtils.updatePermission(
application,
localKey,
type,
null,
permissionKind,
true,
rememberType,
encryptedData = intentData.encryptedData,
decryptTypeScope = decryptTypeScope,
)
permissionsChanged = true
}
@@ -59,6 +59,23 @@ fun LabeledBorderBox(
}
}
@Composable
fun RememberMyChoiceToggles(
selected: RememberType,
onSelected: (RememberType) -> Unit,
) {
LabeledBorderBox(
label = stringResource(R.string.automatically_sign_this_for),
) {
AmberToggles(
selected = selected,
options = rememberTypeDisplayOrder,
onSelected = onSelected,
label = { stringResource(it.shortResourceId) },
)
}
}
@Composable
fun RememberMyChoice(
shouldRunAcceptOrReject: Boolean?,
@@ -79,19 +96,13 @@ fun RememberMyChoice(
}
}
if (packageName != null || alwaysShow) {
LabeledBorderBox(
label = stringResource(R.string.automatically_sign_this_for),
) {
AmberToggles(
selected = selected,
options = rememberTypeDisplayOrder,
onSelected = {
selected = it
onChanged(it)
},
label = { stringResource(it.shortResourceId) },
)
}
RememberMyChoiceToggles(
selected = selected,
onSelected = {
selected = it
onChanged(it)
},
)
}
}
@@ -3,6 +3,7 @@ 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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -17,6 +18,7 @@ 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.res.stringResource
import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.unit.dp
import com.greenart7c3.nostrsigner.R
@@ -28,6 +30,7 @@ import com.greenart7c3.nostrsigner.models.PrivateZapEncryptedDataKind
import com.greenart7c3.nostrsigner.models.SignerType
import com.greenart7c3.nostrsigner.models.TagArrayEncryptedDataKind
import com.greenart7c3.nostrsigner.models.encryptDecryptSignerTypes
import com.greenart7c3.nostrsigner.ui.RememberType
enum class RequestPayloadShape {
EVENT,
@@ -109,6 +112,124 @@ fun RequestGroupKey.toLabel(context: Context): String {
}
}
enum class RequestGroupScopeKind {
NONE,
RELAY_AUTH,
ENCRYPTION_METHOD,
NIP44_V3_KIND,
}
fun RequestGroupKey.scopeKind(): RequestGroupScopeKind = when {
type == SignerType.SIGN_EVENT && kind == 22242 -> RequestGroupScopeKind.RELAY_AUTH
type == SignerType.NIP44_V3_ENCRYPT || type == SignerType.NIP44_V3_DECRYPT -> RequestGroupScopeKind.NIP44_V3_KIND
type in encryptDecryptSignerTypes && type != SignerType.DECRYPT_ZAP_EVENT -> RequestGroupScopeKind.ENCRYPTION_METHOD
else -> RequestGroupScopeKind.NONE
}
// Mirrors the single-event defaults: EncryptDecryptData defaults to ALL, Nip44v3ApprovalData to SPECIFIC
fun defaultDecryptTypeScope(type: SignerType): DecryptTypeScope = if (type == SignerType.NIP44_V3_ENCRYPT || type == SignerType.NIP44_V3_DECRYPT) {
DecryptTypeScope.SPECIFIC
} else {
DecryptTypeScope.ALL
}
// CONNECT / GET_PUBLIC_KEY approvals never persist a permission from the grouped flow
fun RequestGroupKey.hasGroupOptions(): Boolean = type != SignerType.CONNECT && type != SignerType.GET_PUBLIC_KEY
@Composable
fun RequestGroupOptions(
groupKey: RequestGroupKey,
rememberType: RememberType,
onRememberTypeChanged: (RememberType) -> Unit,
relayAuthScope: RelayAuthScope,
onRelayAuthScopeChanged: (RelayAuthScope) -> Unit,
decryptTypeScope: DecryptTypeScope,
onDecryptTypeScopeChanged: (DecryptTypeScope) -> Unit,
) {
Column(
Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 4.dp),
) {
when (groupKey.scopeKind()) {
RequestGroupScopeKind.RELAY_AUTH -> {
LabeledBorderBox(
label = stringResource(R.string.relay_auth_scope),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
) {
AmberToggles(
selected = relayAuthScope,
options = listOf(RelayAuthScope.SPECIFIC, RelayAuthScope.ALL),
onSelected = onRelayAuthScopeChanged,
label = {
stringResource(
when (it) {
RelayAuthScope.SPECIFIC -> R.string.for_this_relay_only
RelayAuthScope.ALL -> R.string.for_all_relays
},
)
},
)
}
}
RequestGroupScopeKind.ENCRYPTION_METHOD -> {
LabeledBorderBox(
label = stringResource(R.string.encryption_scope),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
) {
AmberToggles(
selected = decryptTypeScope,
options = listOf(DecryptTypeScope.SPECIFIC, DecryptTypeScope.ALL),
onSelected = onDecryptTypeScopeChanged,
label = {
stringResource(
when (it) {
DecryptTypeScope.SPECIFIC -> R.string.for_this_method_only
DecryptTypeScope.ALL -> R.string.for_all_methods
},
)
},
)
}
}
RequestGroupScopeKind.NIP44_V3_KIND -> {
if (groupKey.kind != null) {
LabeledBorderBox(
label = stringResource(R.string.encryption_scope),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
) {
AmberToggles(
selected = decryptTypeScope,
options = listOf(DecryptTypeScope.SPECIFIC, DecryptTypeScope.ALL),
onSelected = onDecryptTypeScopeChanged,
label = {
stringResource(
when (it) {
DecryptTypeScope.SPECIFIC -> R.string.for_this_kind_only
DecryptTypeScope.ALL -> R.string.for_all_kinds
},
)
},
)
}
}
}
RequestGroupScopeKind.NONE -> {}
}
RememberMyChoiceToggles(
selected = rememberType,
onSelected = onRememberTypeChanged,
)
}
}
@Composable
fun RequestGroupHeader(
label: String,
@@ -99,6 +99,35 @@ class RequestGroupUtilsTest {
)
}
@Test
fun `scopeKind maps relay auth encryption and v3 groups`() {
assertEquals(RequestGroupScopeKind.RELAY_AUTH, RequestGroupKey(SignerType.SIGN_EVENT, 22242, null).scopeKind())
assertEquals(RequestGroupScopeKind.NONE, RequestGroupKey(SignerType.SIGN_EVENT, 1, null).scopeKind())
assertEquals(RequestGroupScopeKind.ENCRYPTION_METHOD, RequestGroupKey(SignerType.NIP04_ENCRYPT, null, RequestPayloadShape.CLEAR_TEXT).scopeKind())
assertEquals(RequestGroupScopeKind.ENCRYPTION_METHOD, RequestGroupKey(SignerType.NIP44_DECRYPT, 4, RequestPayloadShape.EVENT).scopeKind())
assertEquals(RequestGroupScopeKind.NIP44_V3_KIND, RequestGroupKey(SignerType.NIP44_V3_ENCRYPT, 9, null).scopeKind())
assertEquals(RequestGroupScopeKind.NIP44_V3_KIND, RequestGroupKey(SignerType.NIP44_V3_DECRYPT, null, null).scopeKind())
assertEquals(RequestGroupScopeKind.NONE, RequestGroupKey(SignerType.DECRYPT_ZAP_EVENT, null, RequestPayloadShape.PRIVATE_ZAP).scopeKind())
assertEquals(RequestGroupScopeKind.NONE, RequestGroupKey(SignerType.CONNECT, null, null).scopeKind())
}
@Test
fun `defaultDecryptTypeScope is SPECIFIC for v3 and ALL otherwise`() {
assertEquals(DecryptTypeScope.SPECIFIC, defaultDecryptTypeScope(SignerType.NIP44_V3_ENCRYPT))
assertEquals(DecryptTypeScope.SPECIFIC, defaultDecryptTypeScope(SignerType.NIP44_V3_DECRYPT))
assertEquals(DecryptTypeScope.ALL, defaultDecryptTypeScope(SignerType.NIP04_DECRYPT))
assertEquals(DecryptTypeScope.ALL, defaultDecryptTypeScope(SignerType.NIP44_ENCRYPT))
assertEquals(DecryptTypeScope.ALL, defaultDecryptTypeScope(SignerType.DECRYPT_ZAP_EVENT))
}
@Test
fun `hasGroupOptions excludes connect and get public key`() {
assertEquals(false, RequestGroupKey(SignerType.CONNECT, null, null).hasGroupOptions())
assertEquals(false, RequestGroupKey(SignerType.GET_PUBLIC_KEY, null, null).hasGroupOptions())
assertEquals(true, RequestGroupKey(SignerType.SIGN_EVENT, 1, null).hasGroupOptions())
assertEquals(true, RequestGroupKey(SignerType.NIP44_DECRYPT, null, RequestPayloadShape.CLEAR_TEXT).hasGroupOptions())
}
@Test
fun `groupRequests preserves input order within a group`() {
data class Item(val id: String, val kind: Int)