mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
feat(nip46): scan nostrconnect:// from anywhere and un-gate the connect flow
Two UX gaps made "scan an app's code to connect it to my signer" hard to
reach:
- The signer screen's Connect section (Scan a code / paste) lived behind
the enabled gate, so a first-time user saw only the "Turn on signer"
hero and couldn't scan until they had enabled. Since a successful
nostrconnect pairing already calls setEnabled(true), the gate was
pointless. The Connect section now shows in both states; the success
toast spells out the consequence ("Amethyst now signs for it in the
background").
- The app's general QR scanner (uriToRoute) only understood NIP-19
entities, so scanning a nostrconnect:// code did nothing. It now routes
a nostrconnect:// offer to the signer screen, which pairs the app on
open. Route.Nip46Signer carries an optional connectUri for this.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
This commit is contained in:
@@ -53,6 +53,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupInviteLink
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupNAddrInvite
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectURI
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
@@ -224,6 +225,12 @@ fun uriToRoute(
|
||||
return connectedAppRoute(uri)
|
||||
}
|
||||
|
||||
// A scanned/opened `nostrconnect://` offer is an app asking to connect to our signer: open the
|
||||
// NIP-46 signer screen and let it run the pairing (it enables the signer as part of connecting).
|
||||
if (uri.startsWith(NostrConnectURI.NOSTRCONNECT_SCHEME)) {
|
||||
return Route.Nip46Signer(connectUri = uri)
|
||||
}
|
||||
|
||||
relayGroupInviteRoute(uri)?.let { return it }
|
||||
concordInviteRoute(uri)?.let { return it }
|
||||
|
||||
|
||||
@@ -424,7 +424,7 @@ fun BuildNavigation(
|
||||
composableFromEndArgs<Route.NostrApp>(capWidth = false) { NostrAppScreen(it.coordinate, accountViewModel, nav) }
|
||||
composableFromEnd<Route.ConnectedApps> { ConnectedAppsScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.ConnectedAppDetail> { ConnectedAppDetailScreen(it.coordinate, accountViewModel, nav) }
|
||||
composableFromEnd<Route.Nip46Signer> { Nip46SignerScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.Nip46Signer> { Nip46SignerScreen(accountViewModel, nav, it.connectUri) }
|
||||
composableFromEnd<Route.RelayAuthSettings> { RelayAuthSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.SoftwareAppDetail> { SoftwareAppDetailScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
|
||||
composableFromEnd<Route.Calendars> { CalendarsScreen(accountViewModel, nav) }
|
||||
|
||||
@@ -107,7 +107,10 @@ sealed class Route {
|
||||
|
||||
@Serializable object ConnectedApps : Route()
|
||||
|
||||
@Serializable object Nip46Signer : Route()
|
||||
@Serializable data class Nip46Signer(
|
||||
/** When set (from a scanned/opened `nostrconnect://` offer), the screen connects that app on open. */
|
||||
val connectUri: String? = null,
|
||||
) : Route()
|
||||
|
||||
@Serializable object RelayAuthSettings : Route()
|
||||
|
||||
|
||||
+1
-1
@@ -75,7 +75,7 @@ fun buildSettingsCatalog(
|
||||
symEntry(R.string.profile_badges_title, MaterialSymbols.MilitaryTech, R.string.profile_badges_search_keywords, Route.ProfileBadges),
|
||||
symEntry(R.string.payment_targets, MaterialSymbols.Payment, R.string.payment_targets_search_keywords, Route.EditPaymentTargets),
|
||||
symEntry(R.string.napplet_permissions_title, MaterialSymbols.Apps, R.string.napplet_connected_apps_search_keywords, Route.ConnectedApps),
|
||||
symEntry(R.string.nip46_signer_title, MaterialSymbols.Key, R.string.nip46_signer_search_keywords, Route.Nip46Signer),
|
||||
symEntry(R.string.nip46_signer_title, MaterialSymbols.Key, R.string.nip46_signer_search_keywords, Route.Nip46Signer()),
|
||||
symEntry(R.string.relay_auth_settings_title, MaterialSymbols.Lock, R.string.relay_auth_search_keywords, Route.RelayAuthSettings),
|
||||
symEntry(R.string.security_filters, MaterialSymbols.Security, R.string.security_filters_search_keywords, Route.SecurityFilters),
|
||||
symEntry(R.string.call_settings, MaterialSymbols.Phone, R.string.call_settings_search_keywords, Route.CallSettings),
|
||||
|
||||
+43
-32
@@ -101,6 +101,7 @@ private val LiveGreen = Color(0xFF3DDC84)
|
||||
fun Nip46SignerScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
connectUri: String? = null,
|
||||
) {
|
||||
val account = accountViewModel.account
|
||||
val signer = account.nip46Signer
|
||||
@@ -138,6 +139,11 @@ fun Nip46SignerScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Opened from a scanned/shared nostrconnect:// offer — pair that app on open (this also enables the signer).
|
||||
LaunchedEffect(connectUri) {
|
||||
if (!connectUri.isNullOrBlank()) onConnect(connectUri)
|
||||
}
|
||||
|
||||
if (scanning) {
|
||||
SimpleQrCodeScanner { contents ->
|
||||
scanning = false
|
||||
@@ -162,47 +168,52 @@ fun Nip46SignerScreen(
|
||||
return@Column
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
DisabledHero(onEnable = { signer.setEnabled(true) })
|
||||
return@Column
|
||||
}
|
||||
|
||||
LiveStatusCard(
|
||||
relayCount = relays.size,
|
||||
connectedCount = connectedCount,
|
||||
onToggleOff = { signer.setEnabled(false) },
|
||||
)
|
||||
|
||||
if (relays.isEmpty()) {
|
||||
WarningCard(stringResource(R.string.nip46_signer_status_no_relays))
|
||||
}
|
||||
|
||||
bunkerUri?.let { uri ->
|
||||
QrHeroCard(
|
||||
uri = uri,
|
||||
onCopy = {
|
||||
clipboard.setText(AnnotatedString(uri))
|
||||
Toast.makeText(context, R.string.nip46_signer_copied, Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
onRegenerate = { confirmRotate = true },
|
||||
if (enabled) {
|
||||
LiveStatusCard(
|
||||
relayCount = relays.size,
|
||||
connectedCount = connectedCount,
|
||||
onToggleOff = { signer.setEnabled(false) },
|
||||
)
|
||||
|
||||
if (relays.isEmpty()) {
|
||||
WarningCard(stringResource(R.string.nip46_signer_status_no_relays))
|
||||
}
|
||||
|
||||
bunkerUri?.let { uri ->
|
||||
QrHeroCard(
|
||||
uri = uri,
|
||||
onCopy = {
|
||||
clipboard.setText(AnnotatedString(uri))
|
||||
Toast.makeText(context, R.string.nip46_signer_copied, Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
onRegenerate = { confirmRotate = true },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
DisabledHero(onEnable = { signer.setEnabled(true) })
|
||||
}
|
||||
|
||||
// The Connect section stays reachable even while off: scanning an app's nostrconnect://
|
||||
// code pairs it and enables the signer as part of connecting (no separate "enable" step).
|
||||
ConnectSection(
|
||||
onScan = { scanning = true },
|
||||
onPaste = { onConnect(it) },
|
||||
)
|
||||
|
||||
ConnectedAppsRow(
|
||||
count = connectedCount,
|
||||
onClick = { nav.nav(Route.ConnectedApps) },
|
||||
)
|
||||
if (enabled || connectedCount > 0) {
|
||||
ConnectedAppsRow(
|
||||
count = connectedCount,
|
||||
onClick = { nav.nav(Route.ConnectedApps) },
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
stringResource(R.string.nip46_signer_background_hint),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (enabled) {
|
||||
Text(
|
||||
stringResource(R.string.nip46_signer_background_hint),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -923,8 +923,8 @@
|
||||
<string name="nip46_signer_connect_hint">Paste a nostrconnect:// link</string>
|
||||
<string name="nip46_signer_connect_button">Connect</string>
|
||||
<string name="nip46_signer_manage_apps">Manage connected apps</string>
|
||||
<string name="nip46_signer_connected_ok">App connected</string>
|
||||
<string name="nip46_signer_connected_named">Connected %1$s</string>
|
||||
<string name="nip46_signer_connected_ok">App connected. Amethyst now signs for it in the background.</string>
|
||||
<string name="nip46_signer_connected_named">Connected %1$s. Amethyst now signs for it in the background.</string>
|
||||
<string name="nip46_signer_connect_invalid">Not a valid nostrconnect:// link</string>
|
||||
<string name="nip46_signer_connect_no_relays">That link carries no relay to connect on</string>
|
||||
<string name="nip46_signer_connect_failed">Could not connect: %1$s</string>
|
||||
|
||||
@@ -58,6 +58,12 @@ class UriToRouteTest {
|
||||
assertEquals(Route.Hashtag("foo"), uriToRoute("nostr:hashtag?id=foo", account))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nostrConnectOfferRoutesToTheSignerScreenCarryingTheUri() {
|
||||
val offer = "nostrconnect://" + "b".repeat(64) + "?relay=wss%3A%2F%2Frelay.example.com&secret=abc123"
|
||||
assertEquals(Route.Nip46Signer(connectUri = offer), uriToRoute(offer, account))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fragmentHashtagOrNullExtractsTheTag() {
|
||||
assertEquals("NostrMultiplayerGames", fragmentHashtagOrNull("#NostrMultiplayerGames"))
|
||||
|
||||
Reference in New Issue
Block a user