Redesign multi-request approval screen with Approve/Deny toggles (#497)

Replace the confusing "select + Approve/Discard" interaction on the
multi-event approval screen with an explicit Approve/Deny model:

- Each request card and each group header now has an Approve/Deny
  segmented toggle (approve = primary, deny = error).
- Removed the top "Approve/Deny all" toggle and the per-group
  TriStateCheckbox; group headers carry their own Approve/Deny toggle.
- Replaced the two bottom buttons (Approve selected / Discard selected)
  with a single Confirm button. Confirm commits every per-request
  decision: approve signs and (when remembered) persists an accept rule;
  deny rejects and (when remembered) persists a deny rule, preserving
  existing deny-always behavior.
- Bunker path now sends a proper bunker error response for denied
  requests instead of leaving the client to time out.
- Relabeled the per-group remember control to "Remember my choice for"
  since it now covers both approve and deny rule persistence.
- Fixed deny toggle contrast: selected text now uses onError for the
  deny segment instead of hardcoded black on dark-red.

AmberToggles gains an optional indicatorColor and selectedTextColor;
ToggleOption takes a selectedTextColor param. Removed unused
discard_all/approve_all strings across all locales.

Closes #497
This commit is contained in:
greenart7c3
2026-07-27 15:40:51 -03:00
parent 46802773aa
commit 1549da8838
20 changed files with 449 additions and 709 deletions
@@ -23,6 +23,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
@@ -47,6 +48,8 @@ fun <T> AmberToggles(
onSelected: (T) -> Unit,
modifier: Modifier = Modifier,
label: @Composable (T) -> String,
indicatorColor: @Composable (T) -> Color = { MaterialTheme.colorScheme.primary },
selectedTextColor: @Composable (T) -> Color = { Color.Black },
) {
val count = options.size.coerceAtLeast(1)
val selectedIndex = options.indexOf(selected).coerceAtLeast(0)
@@ -100,7 +103,7 @@ fun <T> AmberToggles(
.width(segmentWidth)
.fillMaxHeight()
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.primary),
.background(indicatorColor(selected)),
)
Row(modifier = Modifier.fillMaxHeight()) {
@@ -109,6 +112,7 @@ fun <T> AmberToggles(
modifier = Modifier.width(segmentWidth),
text = label(option),
isSelected = option == selected,
selectedTextColor = selectedTextColor(option),
onClick = { onSelected(option) },
)
}
@@ -2,22 +2,16 @@ package com.greenart7c3.nostrsigner.ui.components
import android.content.Context
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TriStateCheckbox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -30,7 +24,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.toLowerCase
@@ -58,7 +51,6 @@ import com.greenart7c3.nostrsigner.service.RelayUrlUtils
import com.greenart7c3.nostrsigner.service.model.AmberEvent
import com.greenart7c3.nostrsigner.service.toShortenHex
import com.greenart7c3.nostrsigner.ui.RememberType
import com.greenart7c3.nostrsigner.ui.theme.orange
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse
@@ -89,6 +81,7 @@ fun BunkerMultiEventHomeScreen(
LaunchedEffect(Unit) {
MultiEventScreenIntents.checkedStates.clear()
MultiEventScreenIntents.rememberType = RememberType.NEVER
// checkedStates now holds the per-request decision: true = Approve, false = Deny.
bunkerRequests.forEach { MultiEventScreenIntents.checkedStates[it.request.id] = true }
}
@@ -128,30 +121,6 @@ fun BunkerMultiEventHomeScreen(
SigningAs(accountParam)
val allCheckedState = when {
bunkerRequests.all { MultiEventScreenIntents.checkedStates[it.request.id] ?: true } -> ToggleableState.On
bunkerRequests.none { MultiEventScreenIntents.checkedStates[it.request.id] ?: true } -> ToggleableState.Off
else -> ToggleableState.Indeterminate
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable {
val newValue = allCheckedState != ToggleableState.On
MultiEventScreenIntents.checkedStates.putAll(bunkerRequests.associate { it.request.id to newValue })
},
) {
TriStateCheckbox(
state = allCheckedState,
onClick = {
val newValue = allCheckedState != ToggleableState.On
MultiEventScreenIntents.checkedStates.putAll(bunkerRequests.associate { it.request.id to newValue })
},
)
Text(stringResource(R.string.select_deselect_all))
}
val groups = remember(bunkerRequests) {
groupRequests(bunkerRequests) {
requestGroupKey(
@@ -170,19 +139,14 @@ fun BunkerMultiEventHomeScreen(
val expanded = groups.size == 1 || (expandedGroups[groupKey] ?: false)
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
}
val groupApproved = groupItems.all { MultiEventScreenIntents.checkedStates[it.request.id] ?: true }
RequestGroupHeader(
label = groupKey.toLabel(context),
count = groupItems.size,
state = groupState,
approved = groupApproved,
expanded = expanded,
onToggle = {
val newValue = groupState != ToggleableState.On
MultiEventScreenIntents.checkedStates.putAll(groupItems.associate { it.request.id to newValue })
onApproveChanged = { approve ->
MultiEventScreenIntents.checkedStates.putAll(groupItems.associate { it.request.id to approve })
},
onExpandToggle = {
expandedGroups[groupKey] = !(expandedGroups[groupKey] ?: false)
@@ -208,10 +172,9 @@ fun BunkerMultiEventHomeScreen(
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
approved = MultiEventScreenIntents.checkedStates[bunkerRequest.request.id] ?: true,
onApproveChanged = {
MultiEventScreenIntents.checkedStates[bunkerRequest.request.id] = it
},
)
}
@@ -219,21 +182,20 @@ fun BunkerMultiEventHomeScreen(
}
}
Row(
AmberButton(
Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
AmberButton(
Modifier.weight(1f),
colors = ButtonDefaults.buttonColors().copy(
containerColor = orange,
),
onClick = {
Amber.instance.applicationIOScope.launch {
var closeApp = true
text = stringResource(R.string.confirm),
onClick = {
onLoading(true)
Amber.instance.applicationIOScope.launch(Dispatchers.IO) {
try {
reconnectToRelays()
val closeApp = bunkerRequests.any { it.closeApplication }
BunkerRequestUtils.clearRequests()
EventNotificationConsumer(context).notificationManager().cancelAll()
finishActivity(closeApp)
for (request in bunkerRequests) {
val thisAccount =
if (request.currentAccount.isNotBlank()) {
@@ -247,20 +209,23 @@ fun BunkerMultiEventHomeScreen(
val localKey = request.localKey
val dao = Amber.instance.dao(thisAccount.npub)
val historyDatabase = Amber.instance.getHistoryDatabase(thisAccount.npub)
val savedApplication = dao.getByKey(localKey)
val secret = if (request.request is BunkerRequestConnect) {
request.request.secret ?: ""
} else {
""
}
val application =
dao
.getByKey(localKey) ?: ApplicationWithPermissions(
savedApplication ?: ApplicationWithPermissions(
application = ApplicationEntity(
localKey,
"",
request.clientMetadata?.name ?: "",
listOf(),
"",
"",
request.clientMetadata?.url ?: "",
request.clientMetadata?.image ?: "",
"",
thisAccount.hexKey,
true,
@@ -274,7 +239,7 @@ fun BunkerMultiEventHomeScreen(
permissions = mutableListOf(),
)
val isChecked = MultiEventScreenIntents.checkedStates[request.request.id] ?: true
val isApproved = MultiEventScreenIntents.checkedStates[request.request.id] ?: true
val requestType = BunkerRequestUtils.getTypeFromBunker(request.request)
val groupKey = requestGroupKey(
type = requestType,
@@ -283,165 +248,98 @@ fun BunkerMultiEventHomeScreen(
nip44v3Kind = BunkerRequestUtils.getNip44v3Kind(request.request),
)
val rememberType = groupRememberTypes[groupKey] ?: RememberType.NEVER
if (rememberType != RememberType.NEVER && isChecked) {
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 ((groupRelayAuthScopes[groupKey] ?: RelayAuthScope.SPECIFIC) == RelayAuthScope.ALL) {
"*"
} else {
RelayUrlUtils.extractHostAndPort(AmberEvent.relay(request.request.event))
}
} else {
""
}
AmberUtils.acceptOrRejectPermission(
application,
localKey,
requestType,
rejectKind,
false,
rememberType,
thisAccount,
relay = rejectRelay,
encryptedData = request.encryptedData,
decryptTypeScope = decryptTypeScope,
)
}
if (!application.application.closeApplication) {
closeApp = false
}
}
if (request.request is BunkerRequestSign) {
val localEvent = request.signedEvent!!
EventNotificationConsumer(context).notificationManager().cancelAll()
finishActivity(closeApp)
}
},
text = stringResource(R.string.discard_all),
)
AmberButton(
Modifier.weight(1f),
text = stringResource(R.string.approve_all),
onClick = {
onLoading(true)
Amber.instance.applicationIOScope.launch(Dispatchers.IO) {
try {
reconnectToRelays()
val closeApp = bunkerRequests.any { it.closeApplication }
BunkerRequestUtils.clearRequests()
EventNotificationConsumer(context).notificationManager().cancelAll()
finishActivity(closeApp)
for (request in bunkerRequests) {
val thisAccount =
if (request.currentAccount.isNotBlank()) {
LocalPreferences.loadFromEncryptedStorage(
context,
request.currentAccount,
)
} else {
accountParam
} ?: continue
val localKey = request.localKey
val dao = Amber.instance.dao(thisAccount.npub)
val historyDatabase = Amber.instance.getHistoryDatabase(thisAccount.npub)
val savedApplication = dao.getByKey(localKey)
val secret = if (request.request is BunkerRequestConnect) {
request.request.secret ?: ""
} else {
""
}
val application =
savedApplication ?: ApplicationWithPermissions(
application = ApplicationEntity(
localKey,
request.clientMetadata?.name ?: "",
listOf(),
request.clientMetadata?.url ?: "",
request.clientMetadata?.image ?: "",
"",
thisAccount.hexKey,
true,
secret,
secret.isNotBlank(),
thisAccount.signPolicy,
request.closeApplication,
0L,
lastUsed = TimeUtils.now(),
),
permissions = mutableListOf(),
)
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 ((groupRelayAuthScopes[groupKey] ?: RelayAuthScope.SPECIFIC) == RelayAuthScope.ALL) {
"*"
} else {
RelayUrlUtils.extractHostAndPort(AmberEvent.relay(localEvent))
}
if (rememberType != RememberType.NEVER) {
val signRelay = if (localEvent.kind == 22242) {
if ((groupRelayAuthScopes[groupKey] ?: RelayAuthScope.SPECIFIC) == RelayAuthScope.ALL) {
"*"
} else {
""
RelayUrlUtils.extractHostAndPort(AmberEvent.relay(localEvent))
}
AmberUtils.acceptOrRejectPermission(
application = application,
key = localKey,
signerType = SignerType.SIGN_EVENT,
kind = localEvent.kind,
value = true,
rememberType = rememberType,
account = thisAccount,
relay = signRelay,
encryptedData = request.encryptedData,
)
} else {
""
}
AmberUtils.acceptOrRejectPermission(
application = application,
key = localKey,
signerType = SignerType.SIGN_EVENT,
kind = localEvent.kind,
value = isApproved,
rememberType = rememberType,
account = thisAccount,
relay = signRelay,
encryptedData = request.encryptedData,
)
}
dao.insertApplicationWithPermissions(application)
historyDatabase.dao().addHistory(
listOf(
HistoryEntity(
id = 0,
pkKey = localKey,
type = SignerType.SIGN_EVENT.toString(),
kind = localEvent.kind,
time = TimeUtils.now(),
accepted = isApproved,
content = localEvent.toJson(),
),
),
thisAccount.npub,
)
BunkerRequestUtils.remove(request.request.id)
if (isApproved) {
BunkerRequestUtils.sendBunkerResponse(
context,
thisAccount,
request,
BunkerResponse(request.request.id, localEvent.toJson(), null),
application.application.relays,
onLoading = {},
onDone = {},
)
} else {
AmberUtils.sendBunkerError(
account = thisAccount,
bunkerRequest = request,
relays = application.application.relays,
context = context,
closeApplication = application.application.closeApplication,
onLoading = {},
)
}
} else if (request.request is BunkerRequestConnect) {
if (savedApplication == null) {
dao.insertApplicationWithPermissions(application)
historyDatabase.dao().addHistory(
listOf(
HistoryEntity(
id = 0,
pkKey = localKey,
type = SignerType.SIGN_EVENT.toString(),
kind = localEvent.kind,
time = TimeUtils.now(),
accepted = isChecked,
content = localEvent.toJson(),
0,
localKey,
SignerType.CONNECT.toString(),
null,
TimeUtils.now(),
isApproved,
content = "",
),
),
thisAccount.npub,
)
BunkerRequestUtils.remove(request.request.id)
if (isChecked) {
if (isApproved) {
BunkerRequestUtils.sendBunkerResponse(
context,
thisAccount,
request,
BunkerResponse(request.request.id, localEvent.toJson(), null),
BunkerResponse(request.request.id, "", null),
application.application.relays,
onLoading = {},
onDone = {},
@@ -456,93 +354,54 @@ fun BunkerMultiEventHomeScreen(
onLoading = {},
)
}
} else if (request.request is BunkerRequestConnect) {
if (savedApplication == null) {
dao.insertApplicationWithPermissions(application)
historyDatabase.dao().addHistory(
listOf(
HistoryEntity(
0,
localKey,
SignerType.CONNECT.toString(),
null,
TimeUtils.now(),
isChecked,
content = "",
),
),
thisAccount.npub,
)
BunkerRequestUtils.remove(request.request.id)
if (isChecked) {
BunkerRequestUtils.sendBunkerResponse(
context,
thisAccount,
request,
BunkerResponse(request.request.id, "", null),
application.application.relays,
onLoading = {},
onDone = {},
)
} else {
AmberUtils.sendBunkerError(
account = thisAccount,
bunkerRequest = request,
relays = application.application.relays,
context = context,
closeApplication = application.application.closeApplication,
onLoading = {},
)
}
}
} else {
val type = requestType
if (rememberType != RememberType.NEVER) {
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
}
} else {
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,
permissionKind,
true,
rememberType,
thisAccount,
encryptedData = request.encryptedData,
decryptTypeScope = decryptTypeScope,
)
}
dao.insertApplicationWithPermissions(application)
historyDatabase.dao().addHistory(
listOf(
HistoryEntity(
0,
localKey,
type.toString(),
null,
TimeUtils.now(),
isChecked,
content = if (type == SignerType.NIP04_DECRYPT || type == SignerType.NIP44_DECRYPT || type == SignerType.DECRYPT_ZAP_EVENT) {
request.encryptedData?.result ?: ""
} else {
request.request.params.getOrElse(1) { "" }
},
),
),
thisAccount.npub,
AmberUtils.acceptOrRejectPermission(
application,
localKey,
type,
permissionKind,
isApproved,
rememberType,
thisAccount,
encryptedData = request.encryptedData,
decryptTypeScope = decryptTypeScope,
)
}
val signature = request.encryptedData?.result ?: continue
BunkerRequestUtils.remove(request.request.id)
if (isChecked) {
dao.insertApplicationWithPermissions(application)
historyDatabase.dao().addHistory(
listOf(
HistoryEntity(
0,
localKey,
type.toString(),
null,
TimeUtils.now(),
isApproved,
content = if (type == SignerType.NIP04_DECRYPT || type == SignerType.NIP44_DECRYPT || type == SignerType.DECRYPT_ZAP_EVENT) {
request.encryptedData?.result ?: ""
} else {
request.request.params.getOrElse(1) { "" }
},
),
),
thisAccount.npub,
)
if (isApproved) {
val signature = request.encryptedData?.result
if (signature != null) {
BunkerRequestUtils.remove(request.request.id)
BunkerRequestUtils.sendBunkerResponse(
context,
thisAccount,
@@ -552,25 +411,26 @@ fun BunkerMultiEventHomeScreen(
onLoading = {},
onDone = {},
)
} else {
AmberUtils.sendBunkerError(
account = thisAccount,
bunkerRequest = request,
relays = application.application.relays,
context = context,
closeApplication = application.application.closeApplication,
onLoading = {},
)
}
} else {
BunkerRequestUtils.remove(request.request.id)
AmberUtils.sendBunkerError(
account = thisAccount,
bunkerRequest = request,
relays = application.application.relays,
context = context,
closeApplication = application.application.closeApplication,
onLoading = {},
)
}
}
} finally {
onLoading(false)
}
} finally {
onLoading(false)
}
},
)
}
}
},
)
}
}
@@ -578,8 +438,8 @@ fun BunkerMultiEventHomeScreen(
private fun BunkerRequestCard(
context: Context,
bunkerRequest: AmberBunkerRequest,
checked: Boolean,
onToggleChecked: () -> Unit,
approved: Boolean,
onApproveChanged: (Boolean) -> Unit,
) {
val type = BunkerRequestUtils.getTypeFromBunker(bunkerRequest.request)
var showDetails by remember { mutableStateOf(false) }
@@ -659,42 +519,49 @@ private fun BunkerRequestCard(
),
border = BorderStroke(1.dp, Color.Gray),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { onToggleChecked() },
Column(
Modifier.padding(8.dp),
) {
Checkbox(
checked = checked,
onCheckedChange = { onToggleChecked() },
colors = CheckboxDefaults.colors().copy(
uncheckedBorderColor = Color.Gray,
),
)
Column(
Modifier
.weight(1f)
.padding(top = 8.dp, bottom = 8.dp, end = 8.dp),
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = label,
color = if (checked) Color.Unspecified else Color.Gray,
)
if (preview.isNotBlank()) {
Text(
text = preview,
color = Color.Gray,
maxLines = 2,
)
}
if (hasDetails) {
RawJsonButton(
onCLick = { showDetails = true },
text = stringResource(R.string.show_details),
)
Column(
Modifier
.weight(1f)
.padding(end = 8.dp),
) {
Text(text = label)
if (preview.isNotBlank()) {
Text(
text = preview,
color = Color.Gray,
maxLines = 2,
)
}
if (hasDetails) {
RawJsonButton(
onCLick = { showDetails = true },
text = stringResource(R.string.show_details),
)
}
}
}
AmberToggles(
selected = approved,
options = listOf(true, false),
onSelected = onApproveChanged,
label = {
if (it) stringResource(R.string.approve) else stringResource(R.string.deny)
},
indicatorColor = {
if (it) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
},
selectedTextColor = {
if (it) Color.Black else MaterialTheme.colorScheme.onError
},
)
}
}
@@ -4,22 +4,16 @@ import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TriStateCheckbox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -32,7 +26,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.toLowerCase
@@ -58,7 +51,6 @@ import com.greenart7c3.nostrsigner.service.MultiEventScreenIntents
import com.greenart7c3.nostrsigner.service.RelayUrlUtils
import com.greenart7c3.nostrsigner.service.model.AmberEvent
import com.greenart7c3.nostrsigner.ui.RememberType
import com.greenart7c3.nostrsigner.ui.theme.orange
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
@@ -82,6 +74,7 @@ fun IntentMultiEventHomeScreen(
LaunchedEffect(Unit) {
MultiEventScreenIntents.checkedStates.clear()
MultiEventScreenIntents.rememberType = RememberType.NEVER
// checkedStates now holds the per-request decision: true = Approve, false = Deny.
intents.forEach { MultiEventScreenIntents.checkedStates[it.id] = true }
}
@@ -99,30 +92,6 @@ fun IntentMultiEventHomeScreen(
SigningAs(accountParam)
val allCheckedState = when {
intents.all { MultiEventScreenIntents.checkedStates[it.id] ?: true } -> ToggleableState.On
intents.none { MultiEventScreenIntents.checkedStates[it.id] ?: true } -> ToggleableState.Off
else -> ToggleableState.Indeterminate
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable {
val newValue = allCheckedState != ToggleableState.On
MultiEventScreenIntents.checkedStates.putAll(intents.associate { it.id to newValue })
},
) {
TriStateCheckbox(
state = allCheckedState,
onClick = {
val newValue = allCheckedState != ToggleableState.On
MultiEventScreenIntents.checkedStates.putAll(intents.associate { it.id to newValue })
},
)
Text(stringResource(R.string.select_deselect_all))
}
val groups = remember(intents) {
groupRequests(intents) {
requestGroupKey(it.type, it.event?.kind, it.encryptedData, it.nip44v3Kind)
@@ -136,19 +105,14 @@ fun IntentMultiEventHomeScreen(
val expanded = groups.size == 1 || (expandedGroups[groupKey] ?: false)
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
}
val groupApproved = groupIntents.all { MultiEventScreenIntents.checkedStates[it.id] ?: true }
RequestGroupHeader(
label = groupKey.toLabel(context),
count = groupIntents.size,
state = groupState,
approved = groupApproved,
expanded = expanded,
onToggle = {
val newValue = groupState != ToggleableState.On
MultiEventScreenIntents.checkedStates.putAll(groupIntents.associate { it.id to newValue })
onApproveChanged = { approve ->
MultiEventScreenIntents.checkedStates.putAll(groupIntents.associate { it.id to approve })
},
onExpandToggle = {
expandedGroups[groupKey] = !(expandedGroups[groupKey] ?: false)
@@ -174,10 +138,9 @@ fun IntentMultiEventHomeScreen(
IntentRequestCard(
context = context,
intent = intent,
checked = MultiEventScreenIntents.checkedStates[intent.id] ?: true,
onToggleChecked = {
val current = MultiEventScreenIntents.checkedStates[intent.id] ?: true
MultiEventScreenIntents.checkedStates[intent.id] = !current
approved = MultiEventScreenIntents.checkedStates[intent.id] ?: true,
onApproveChanged = {
MultiEventScreenIntents.checkedStates[intent.id] = it
},
)
}
@@ -185,19 +148,16 @@ fun IntentMultiEventHomeScreen(
}
}
Row(
AmberButton(
Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
AmberButton(
Modifier.weight(1f),
colors = ButtonDefaults.buttonColors().copy(
containerColor = orange,
),
onClick = {
Amber.instance.applicationIOScope.launch(Dispatchers.IO) {
text = stringResource(R.string.confirm),
onClick = {
onLoading(true)
Amber.instance.applicationIOScope.launch(Dispatchers.IO) {
try {
val results = mutableListOf<Result>()
var closeApp = true
onRemoveIntentData(intents, IntentResultType.REMOVE)
val localKey = packageName ?: return@launch
@@ -211,6 +171,7 @@ fun IntentMultiEventHomeScreen(
} ?: continue
val dao = Amber.instance.dao(thisAccount.npub)
val historyDatabase = Amber.instance.getHistoryDatabase(thisAccount.npub)
val application = dao.getByKey(localKey) ?: ApplicationWithPermissions(
application = ApplicationEntity(
localKey,
@@ -236,247 +197,163 @@ fun IntentMultiEventHomeScreen(
}
var permissionsChanged = false
val historyList = mutableListOf<HistoryEntity>()
for (intentData in accountIntents) {
val isChecked = MultiEventScreenIntents.checkedStates[intentData.id] ?: true
val groupKey = requestGroupKey(intentData.type, intentData.event?.kind, intentData.encryptedData, intentData.nip44v3Kind)
val isApproved = 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 (rememberType != RememberType.NEVER && isChecked) {
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 ((groupRelayAuthScopes[groupKey] ?: RelayAuthScope.SPECIFIC) == RelayAuthScope.ALL) {
"*"
if (type == SignerType.SIGN_EVENT) {
val localEvent = intentData.event!!
if (rememberType != RememberType.NEVER) {
val signRelay = if (localEvent.kind == 22242) {
if ((groupRelayAuthScopes[groupKey] ?: RelayAuthScope.SPECIFIC) == RelayAuthScope.ALL) {
"*"
} else {
RelayUrlUtils.extractHostAndPort(AmberEvent.relay(localEvent))
}
} else {
RelayUrlUtils.extractHostAndPort(AmberEvent.relay(intentData.event))
""
}
AmberUtils.updatePermission(
application,
localKey,
type,
localEvent.kind,
isApproved,
rememberType,
relay = signRelay,
encryptedData = intentData.encryptedData,
)
permissionsChanged = true
}
historyList.add(
HistoryEntity(
0,
localKey,
type.toString(),
localEvent.kind,
TimeUtils.now(),
isApproved,
content = localEvent.toJson(),
),
)
if (isApproved) {
val signature = if (localEvent is LnZapRequestEvent &&
localEvent.tags.any { tag ->
tag.any { t -> t == "anon" }
}
) {
localEvent.toJson()
} else {
localEvent.sig
}
results.add(
Result(
null,
signature = signature,
result = signature,
id = intentData.id,
rejected = null,
),
)
} else {
results.add(
Result(
null,
signature = null,
result = null,
id = intentData.id,
rejected = true,
),
)
}
} else {
if (rememberType != RememberType.NEVER) {
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,
permissionKind,
isApproved,
rememberType,
encryptedData = intentData.encryptedData,
decryptTypeScope = decryptTypeScope,
)
permissionsChanged = true
}
historyList.add(
HistoryEntity(
0,
localKey,
type.toString(),
null,
TimeUtils.now(),
isApproved,
content = if (type == SignerType.NIP04_DECRYPT || type == SignerType.NIP44_DECRYPT || type == SignerType.DECRYPT_ZAP_EVENT) {
intentData.encryptedData?.result ?: ""
} else {
intentData.data
},
),
)
if (isApproved) {
val signature = intentData.encryptedData?.result
if (signature != null) {
results.add(
Result(
null,
signature = signature,
result = signature,
id = intentData.id,
rejected = null,
),
)
}
} else {
""
results.add(
Result(
null,
signature = null,
result = null,
id = intentData.id,
rejected = true,
),
)
}
AmberUtils.updatePermission(
application,
localKey,
intentData.type,
rejectKind,
false,
rememberType,
relay = rejectRelay,
encryptedData = intentData.encryptedData,
decryptTypeScope = decryptTypeScope,
)
permissionsChanged = true
}
}
if (permissionsChanged || application.application.key.isBlank()) {
dao.insertApplicationWithPermissions(application)
}
historyDatabase.dao().addHistory(historyList, thisAccount.npub)
}
if (results.isNotEmpty()) {
sendResultIntent(results)
}
sendRejectIntent(
results = intents.map {
Result(
null,
signature = null,
result = null,
id = it.id,
rejected = true,
)
}.toMutableList(),
)
finishActivity(closeApp)
} finally {
onLoading(false)
}
},
text = stringResource(R.string.discard_all),
)
AmberButton(
Modifier.weight(1f),
text = stringResource(R.string.approve_all),
onClick = {
onLoading(true)
Amber.instance.applicationIOScope.launch(Dispatchers.IO) {
try {
val results = mutableListOf<Result>()
var closeApp = true
onRemoveIntentData(intents, IntentResultType.REMOVE)
val localKey = packageName ?: return@launch
val intentsByAccount = intents.groupBy { it.currentAccount.ifBlank { accountParam.npub } }
for ((accountNpub, accountIntents) in intentsByAccount) {
val thisAccount = if (accountNpub == accountParam.npub) {
accountParam
} else {
LocalPreferences.loadFromEncryptedStorage(context, accountNpub)
} ?: continue
val dao = Amber.instance.dao(thisAccount.npub)
val historyDatabase = Amber.instance.getHistoryDatabase(thisAccount.npub)
val application = dao.getByKey(localKey) ?: ApplicationWithPermissions(
application = ApplicationEntity(
localKey,
"",
listOf(),
"",
"",
"",
thisAccount.hexKey,
true,
"",
false,
thisAccount.signPolicy,
true,
0L,
lastUsed = TimeUtils.now(),
),
permissions = mutableListOf(),
)
if (!application.application.closeApplication) {
closeApp = false
}
var permissionsChanged = false
val historyList = mutableListOf<HistoryEntity>()
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 ((groupRelayAuthScopes[groupKey] ?: RelayAuthScope.SPECIFIC) == RelayAuthScope.ALL) {
"*"
} else {
RelayUrlUtils.extractHostAndPort(AmberEvent.relay(localEvent))
}
} else {
""
}
AmberUtils.updatePermission(
application,
localKey,
type,
localEvent.kind,
true,
rememberType,
relay = signRelay,
encryptedData = intentData.encryptedData,
)
permissionsChanged = true
}
historyList.add(
HistoryEntity(
0,
localKey,
type.toString(),
localEvent.kind,
TimeUtils.now(),
isChecked,
content = localEvent.toJson(),
),
)
if (isChecked) {
val signature = if (localEvent is LnZapRequestEvent &&
localEvent.tags.any { tag ->
tag.any { t -> t == "anon" }
}
) {
localEvent.toJson()
} else {
localEvent.sig
}
results.add(
Result(
null,
signature = signature,
result = signature,
id = intentData.id,
rejected = null,
),
)
}
} 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,
permissionKind,
true,
rememberType,
encryptedData = intentData.encryptedData,
decryptTypeScope = decryptTypeScope,
)
permissionsChanged = true
}
historyList.add(
HistoryEntity(
0,
localKey,
type.toString(),
null,
TimeUtils.now(),
isChecked,
content = if (type == SignerType.NIP04_DECRYPT || type == SignerType.NIP44_DECRYPT || type == SignerType.DECRYPT_ZAP_EVENT) {
intentData.encryptedData?.result ?: ""
} else {
intentData.data
},
),
)
val signature = intentData.encryptedData?.result
if (isChecked && signature != null) {
results.add(
Result(
null,
signature = signature,
result = signature,
id = intentData.id,
rejected = null,
),
)
}
}
}
if (permissionsChanged || application.application.key.isBlank()) {
dao.insertApplicationWithPermissions(application)
}
historyDatabase.dao().addHistory(historyList, thisAccount.npub)
}
if (results.isNotEmpty()) {
sendResultIntent(results)
}
finishActivity(closeApp)
} finally {
onLoading(false)
}
}
},
)
}
}
},
)
}
}
@@ -484,8 +361,8 @@ fun IntentMultiEventHomeScreen(
private fun IntentRequestCard(
context: Context,
intent: IntentData,
checked: Boolean,
onToggleChecked: () -> Unit,
approved: Boolean,
onApproveChanged: (Boolean) -> Unit,
) {
val type = intent.type
var showDetails by remember { mutableStateOf(false) }
@@ -569,42 +446,49 @@ private fun IntentRequestCard(
),
border = BorderStroke(1.dp, Color.Gray),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { onToggleChecked() },
Column(
Modifier.padding(8.dp),
) {
Checkbox(
checked = checked,
onCheckedChange = { onToggleChecked() },
colors = CheckboxDefaults.colors().copy(
uncheckedBorderColor = Color.Gray,
),
)
Column(
Modifier
.weight(1f)
.padding(top = 8.dp, bottom = 8.dp, end = 8.dp),
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = label,
color = if (checked) Color.Unspecified else Color.Gray,
)
if (preview.isNotBlank()) {
Text(
text = preview,
color = Color.Gray,
maxLines = 2,
)
}
if (hasDetails) {
RawJsonButton(
onCLick = { showDetails = true },
text = stringResource(R.string.show_details),
)
Column(
Modifier
.weight(1f)
.padding(end = 8.dp),
) {
Text(text = label)
if (preview.isNotBlank()) {
Text(
text = preview,
color = Color.Gray,
maxLines = 2,
)
}
if (hasDetails) {
RawJsonButton(
onCLick = { showDetails = true },
text = stringResource(R.string.show_details),
)
}
}
}
AmberToggles(
selected = approved,
options = listOf(true, false),
onSelected = onApproveChanged,
label = {
if (it) stringResource(R.string.approve) else stringResource(R.string.deny)
},
indicatorColor = {
if (it) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
},
selectedTextColor = {
if (it) Color.Black else MaterialTheme.colorScheme.onError
},
)
}
}
@@ -632,15 +516,6 @@ private fun finishActivity(closeApp: Boolean) {
}
}
private fun sendRejectIntent(
results: MutableList<Result>,
) {
val json = Permission.mapper.writeValueAsString(results)
val intent = Intent()
intent.putExtra("results", json)
Amber.instance.getMainActivity()?.setResult(Activity.RESULT_OK, intent)
}
private fun sendResultIntent(
results: MutableList<Result>,
) {
@@ -63,9 +63,10 @@ fun LabeledBorderBox(
fun RememberMyChoiceToggles(
selected: RememberType,
onSelected: (RememberType) -> Unit,
label: String = stringResource(R.string.automatically_sign_this_for),
) {
LabeledBorderBox(
label = stringResource(R.string.automatically_sign_this_for),
label = label,
) {
AmberToggles(
selected = selected,
@@ -12,14 +12,13 @@ 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.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.unit.dp
import com.greenart7c3.nostrsigner.R
import com.greenart7c3.nostrsigner.models.ClearTextEncryptedDataKind
@@ -226,6 +225,7 @@ fun RequestGroupOptions(
RememberMyChoiceToggles(
selected = rememberType,
onSelected = onRememberTypeChanged,
label = stringResource(R.string.remember_my_choice_for),
)
}
}
@@ -234,36 +234,51 @@ fun RequestGroupOptions(
fun RequestGroupHeader(
label: String,
count: Int,
state: ToggleableState,
approved: Boolean,
expanded: Boolean,
onToggle: () -> Unit,
onApproveChanged: (Boolean) -> 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() },
Column(
Modifier.fillMaxWidth(),
) {
TriStateCheckbox(
state = state,
onClick = onToggle,
)
Text(
text = "$label ($count)",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
)
Icon(
imageVector = Icons.Default.ExpandMore,
contentDescription = null,
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(end = 12.dp)
.rotate(rotation),
.fillMaxWidth()
.clickable { onExpandToggle() },
) {
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),
)
}
AmberToggles(
selected = approved,
options = listOf(true, false),
onSelected = onApproveChanged,
label = {
if (it) stringResource(R.string.approve) else stringResource(R.string.deny)
},
indicatorColor = {
if (it) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
},
selectedTextColor = {
if (it) Color.Black else MaterialTheme.colorScheme.onError
},
)
}
}
@@ -25,11 +25,12 @@ import com.greenart7c3.nostrsigner.ui.theme.ThemePreviews
fun ToggleOption(
text: String,
isSelected: Boolean,
selectedTextColor: Color,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
val textColor by animateColorAsState(
targetValue = if (isSelected) Color.Black else MaterialTheme.colorScheme.onSurfaceVariant,
targetValue = if (isSelected) selectedTextColor else MaterialTheme.colorScheme.onSurfaceVariant,
animationSpec = tween(durationMillis = 200),
label = "textColor",
)
@@ -59,12 +60,14 @@ fun ToggleOptionPreview() {
ToggleOption(
text = "Always",
isSelected = true,
selectedTextColor = Color.Black,
modifier = Modifier.width(80.dp),
onClick = {},
)
ToggleOption(
text = "Never",
isSelected = false,
selectedTextColor = Color.Black,
modifier = Modifier.width(80.dp),
onClick = {},
)
-2
View File
@@ -630,8 +630,6 @@
<string name="pubkey">Pubkey</string>
<string name="date">Datum</string>
<string name="tags">Tags</string>
<string name="discard_all">Auswahl verwerfen</string>
<string name="approve_all">Auswahl genehmigen</string>
<string name="requests_message">Anfragen %1$s</string>
<string name="trust_excellent">Ausgezeichnet</string>
<string name="trust_good">Gut</string>
-2
View File
@@ -633,8 +633,6 @@
<string name="pubkey">Clave pública</string>
<string name="date">Fecha</string>
<string name="tags">Etiquetas</string>
<string name="discard_all">Descartar seleccionados</string>
<string name="approve_all">Aprobar seleccionados</string>
<string name="requests_message">Solicitudes %1$s</string>
<string name="trust_excellent">Excelente</string>
<string name="trust_good">Bueno</string>
-2
View File
@@ -630,8 +630,6 @@
<string name="pubkey">Pubkey</string>
<string name="date">Date</string>
<string name="tags">Tags</string>
<string name="discard_all">Rejeter la sélection</string>
<string name="approve_all">Approuver la sélection</string>
<string name="requests_message">Requêtes %1$s</string>
<string name="trust_excellent">Excellent</string>
<string name="trust_good">Bon</string>
-2
View File
@@ -633,8 +633,6 @@
<string name="pubkey">Pubkey</string>
<string name="date">Tanggal</string>
<string name="tags">Tag</string>
<string name="discard_all">Buang yang dipilih</string>
<string name="approve_all">Setujui yang dipilih</string>
<string name="requests_message">Permintaan %1$s</string>
<string name="trust_excellent">Sangat baik</string>
<string name="trust_good">Baik</string>
@@ -633,8 +633,6 @@
<string name="pubkey">Chiave pubblica</string>
<string name="date">Data</string>
<string name="tags">Tag</string>
<string name="discard_all">Scarta selezionati</string>
<string name="approve_all">Approva selezionati</string>
<string name="requests_message">Richieste %1$s</string>
<string name="trust_excellent">Eccellente</string>
<string name="trust_good">Buono</string>
-2
View File
@@ -609,8 +609,6 @@
<string name="pubkey">Pubkey</string>
<string name="date">日付</string>
<string name="tags">タグ</string>
<string name="discard_all">選択した項目を破棄</string>
<string name="approve_all">選択した項目を承認</string>
<string name="requests_message">リクエスト %1$s</string>
<string name="trust_excellent">非常に高い</string>
<string name="trust_good">高い</string>
@@ -633,8 +633,6 @@
<string name="pubkey">공개 키</string>
<string name="date">날짜</string>
<string name="tags">태그</string>
<string name="discard_all">선택 항목 버리기</string>
<string name="approve_all">선택 항목 승인</string>
<string name="requests_message">요청 %1$s</string>
<string name="trust_excellent">매우 좋음</string>
<string name="trust_good">좋음</string>
@@ -628,8 +628,6 @@
<string name="pubkey">Chave</string>
<string name="date">Data</string>
<string name="tags">Tags</string>
<string name="discard_all">Descartar selecionado</string>
<string name="approve_all">Aprovar selecionados</string>
<string name="requests_message">Solicitações %1$s</string>
<string name="trust_excellent">Excelente</string>
<string name="trust_good">Bom</string>
-2
View File
@@ -633,8 +633,6 @@
<string name="pubkey">Публичный ключ</string>
<string name="date">Дата</string>
<string name="tags">Теги</string>
<string name="discard_all">Отклонить выбранные</string>
<string name="approve_all">Одобрить выбранные</string>
<string name="requests_message">Запросы %1$s</string>
<string name="trust_excellent">Отлично</string>
<string name="trust_good">Хорошо</string>
-2
View File
@@ -609,8 +609,6 @@
<string name="pubkey">Pubkey</string>
<string name="date">วันที่</string>
<string name="tags">แท็ก</string>
<string name="discard_all">ยกเลิกรายการที่เลือก</string>
<string name="approve_all">อนุมัติรายการที่เลือก</string>
<string name="requests_message">คำขอ %1$s</string>
<string name="trust_excellent">ยอดเยี่ยม</string>
<string name="trust_good">ดี</string>
-2
View File
@@ -629,8 +629,6 @@
<string name="pubkey">Pubkey (Açık anahtar)</string>
<string name="date">Tarih</string>
<string name="tags">Etiketler</string>
<string name="discard_all">Seçilenleri yoksay</string>
<string name="approve_all">Seçilenleri onayla</string>
<string name="requests_message">İstekler: %1$s</string>
<string name="trust_excellent">Mükemmel</string>
<string name="trust_good">İyi</string>
@@ -609,8 +609,6 @@
<string name="pubkey">Pubkey</string>
<string name="date">Ngày</string>
<string name="tags">Thẻ</string>
<string name="discard_all">Bỏ qua đã chọn</string>
<string name="approve_all">Phê duyệt đã chọn</string>
<string name="requests_message">Yêu cầu %1$s</string>
<string name="trust_excellent">Xuất sắc</string>
<string name="trust_good">Tốt</string>
-2
View File
@@ -614,8 +614,6 @@
<string name="pubkey">公钥</string>
<string name="date">日期</string>
<string name="tags">标签</string>
<string name="discard_all">拒绝所选</string>
<string name="approve_all">批准所选</string>
<string name="requests_message">请求 %1$s</string>
<string name="trust_excellent">极佳</string>
<string name="trust_good">良好</string>
+3 -2
View File
@@ -661,8 +661,9 @@
<string name="pubkey">Pubkey</string>
<string name="date">Date</string>
<string name="tags">Tags</string>
<string name="discard_all">Discard selected</string>
<string name="approve_all">Approve selected</string>
<string name="approve">Approve</string>
<string name="confirm">Confirm</string>
<string name="remember_my_choice_for">Remember my choice for</string>
<string name="requests_message">Requests %1$s</string>
<string name="trust_excellent">Excellent</string>
<string name="trust_good">Good</string>