commit ae8e238394cc742b9187aee8c6069301ef2e8a53
parent de2218bc2aa103a506362cc94c496c6245bce8d1
Author: triesap <tyson@radroots.org>
Date: Sun, 2 Aug 2026 19:55:38 +0000
ui(accounts): map AppCore snapshots for Compose
- derive immutable presentation models from public Rust snapshots
- shorten Nostr identities without changing canonical public keys
- map relay profile and safe failure states into display text
- isolate one-time secret receipts from persisted application state
Diffstat:
3 files changed, 197 insertions(+), 1 deletion(-)
diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/AccountsUiModel.kt b/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/AccountsUiModel.kt
@@ -0,0 +1,105 @@
+package org.radroots.studio.accounts.ui
+
+import org.radroots.studio.application.StudioStoreState
+import org.radroots.studio.ffi.AccountDto
+import org.radroots.studio.ffi.ActiveAccountDto
+import org.radroots.studio.ffi.ProfileLoadStateDto
+import org.radroots.studio.ffi.RelayConnectionStateDto
+import org.radroots.studio.ffi.SessionStateDto
+
+data class AccountUiModel(
+ val publicKeyHex: String,
+ val npub: String,
+ val shortNpub: String,
+ val label: String,
+ val keyAvailability: String,
+ val selected: Boolean,
+)
+
+data class ProfileUiModel(
+ val name: String,
+ val displayName: String,
+ val nip05: String,
+ val about: String,
+ val picture: String,
+)
+
+data class ActiveAccountUiModel(
+ val account: AccountUiModel,
+ val heading: String,
+ val relayState: String,
+ val profileState: String,
+ val profile: ProfileUiModel,
+)
+
+data class GeneratedKeyBackupUiModel(
+ val npub: String,
+ val nsec: String,
+)
+
+data class StudioUiModel(
+ val accounts: List<AccountUiModel>,
+ val activeAccount: ActiveAccountUiModel?,
+ val configuredRelays: List<String>,
+ val importDraft: String,
+ val generatedKeyBackup: GeneratedKeyBackupUiModel?,
+ val pendingRemovalPublicKeyHex: String?,
+ val session: SessionStateDto,
+ val busy: Boolean,
+ val problem: String?,
+)
+
+fun StudioStoreState.toUiModel(): StudioUiModel {
+ val selectedPublicKeyHex = snapshot.selectedPublicKeyHex
+ val accounts = snapshot.accounts.map { it.toUiModel(it.publicKeyHex == selectedPublicKeyHex) }
+ return StudioUiModel(
+ accounts = accounts,
+ activeAccount = snapshot.activeAccount?.toUiModel(selectedPublicKeyHex),
+ configuredRelays = snapshot.configuredRelays,
+ importDraft = importDraft,
+ generatedKeyBackup = generatedKeyBackup?.let {
+ GeneratedKeyBackupUiModel(npub = it.npub, nsec = it.nsec)
+ },
+ pendingRemovalPublicKeyHex = pendingRemovalPublicKeyHex,
+ session = snapshot.session,
+ busy = busy,
+ problem = problem
+ ?: snapshot.recoverableProblem?.message
+ ?: snapshot.sessionError?.message
+ ?: snapshot.lifecycleError?.message,
+ )
+}
+
+fun shortenNpub(npub: String): String =
+ if (npub.length <= 24) npub else "${npub.take(14)}…${npub.takeLast(8)}"
+
+private fun AccountDto.toUiModel(selected: Boolean) = AccountUiModel(
+ publicKeyHex = publicKeyHex,
+ npub = npub,
+ shortNpub = shortenNpub(npub),
+ label = displayLabel.ifBlank { shortenNpub(npub) },
+ keyAvailability = keyAvailability.name.lowercase().replace('_', ' '),
+ selected = selected,
+)
+
+private fun ActiveAccountDto.toUiModel(selectedPublicKeyHex: String?) = ActiveAccountUiModel(
+ account = account.toUiModel(account.publicKeyHex == selectedPublicKeyHex),
+ heading = profile?.displayName?.takeIf(String::isNotBlank)
+ ?: profile?.name?.takeIf(String::isNotBlank)
+ ?: account.displayLabel.ifBlank { shortenNpub(account.npub) },
+ relayState = relayState.toDisplayText(),
+ profileState = profileState.toDisplayText(),
+ profile = ProfileUiModel(
+ name = profile?.name.orEmpty(),
+ displayName = profile?.displayName.orEmpty(),
+ nip05 = profile?.nip05.orEmpty(),
+ about = profile?.about.orEmpty(),
+ picture = profile?.picture.orEmpty(),
+ ),
+)
+
+private fun RelayConnectionStateDto.toDisplayText(): String =
+ name.lowercase().replace('_', ' ')
+
+private fun ProfileLoadStateDto.toDisplayText(): String =
+ name.lowercase().replace('_', ' ')
diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/AccountsUiModelTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/AccountsUiModelTest.kt
@@ -0,0 +1,91 @@
+package org.radroots.studio.accounts.ui
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import org.radroots.studio.application.GeneratedKeyBackup
+import org.radroots.studio.application.StudioStoreState
+import org.radroots.studio.ffi.AccountDto
+import org.radroots.studio.ffi.ActiveAccountDto
+import org.radroots.studio.ffi.AppLifecycleDto
+import org.radroots.studio.ffi.AppSnapshotDto
+import org.radroots.studio.ffi.KeyAvailabilityDto
+import org.radroots.studio.ffi.ProfileDto
+import org.radroots.studio.ffi.ProfileLoadStateDto
+import org.radroots.studio.ffi.RelayConnectionStateDto
+import org.radroots.studio.ffi.SessionStateDto
+import org.radroots.studio.ffi.SignerKindDto
+
+class AccountsUiModelTest {
+ @Test
+ fun mapsPublicNostrIdentityAndProfileState() {
+ val account = account()
+ val snapshot = snapshot(
+ account = account,
+ active = ActiveAccountDto(
+ account = account,
+ relayState = RelayConnectionStateDto.CONNECTED,
+ profileState = ProfileLoadStateDto.FRESH,
+ profile = ProfileDto("alice", "Alice", "alice@example.com", "Farmer", "https://example.com/a.png"),
+ ),
+ )
+
+ val model = StudioStoreState(snapshot).toUiModel()
+
+ assertEquals("Alice", model.activeAccount?.heading)
+ assertEquals("connected", model.activeAccount?.relayState)
+ assertEquals("fresh", model.activeAccount?.profileState)
+ assertEquals("alice@example.com", model.activeAccount?.profile?.nip05)
+ assertEquals(listOf("ws://localhost:8080"), model.configuredRelays)
+ assertFalse(model.accounts.single().label.contains("server", ignoreCase = true))
+ }
+
+ @Test
+ fun mapsSafeProblemAndTransientBackupSeparatelyFromSnapshot() {
+ val state = StudioStoreState(
+ snapshot = snapshot(),
+ generatedKeyBackup = GeneratedKeyBackup("npub1generated", "nsec1generated"),
+ problem = "Try again.",
+ )
+
+ val model = state.toUiModel()
+
+ assertEquals("Try again.", model.problem)
+ assertEquals("nsec1generated", model.generatedKeyBackup?.nsec)
+ assertNull(state.snapshot.recoverableProblem)
+ }
+
+ @Test
+ fun shortensOnlyLongNpubValues() {
+ assertEquals("npub1short", shortenNpub("npub1short"))
+ assertEquals("npub1abcdefghi…34567890", shortenNpub("npub1abcdefghijklmnopqrstuvwxyz1234567890"))
+ }
+}
+
+private fun snapshot(
+ account: AccountDto? = null,
+ active: ActiveAccountDto? = null,
+) = AppSnapshotDto(
+ revision = 1UL,
+ lifecycle = AppLifecycleDto.READY,
+ lifecycleError = null,
+ configuredRelays = listOf("ws://localhost:8080"),
+ accounts = listOfNotNull(account),
+ selectedPublicKeyHex = account?.publicKeyHex,
+ session = if (active == null) SessionStateDto.SIGNED_OUT else SessionStateDto.ACTIVE,
+ sessionSubjectPublicKeyHex = active?.account?.publicKeyHex,
+ sessionError = null,
+ activeAccount = active,
+ recoverableProblem = null,
+)
+
+private fun account() = AccountDto(
+ publicKeyHex = "12".repeat(32),
+ npub = "npub1abcdefghijklmnopqrstuvwxyz1234567890",
+ displayLabel = "Alice",
+ signerKind = SignerKindDto.LOCAL_SECRET,
+ keyAvailability = KeyAvailabilityDto.AVAILABLE,
+ createdAtSeconds = 1,
+ lastUsedAtSeconds = null,
+)
diff --git a/docs/implementation/nostr-runtime-rcld.md b/docs/implementation/nostr-runtime-rcld.md
@@ -533,7 +533,7 @@ handoff commit sequence.
- [x] 48. Add Kotlin StudioAppStore thin adapter.
- [x] 49. Bootstrap AppCore once at application root.
-- [ ] 50. Create UI model mapping helpers without server fields.
+- [x] 50. Create UI model mapping helpers without server fields.
- [ ] 51. Implement inactive account screen generate/import controls.
- [ ] 52. Implement generated-key backup panel.
- [ ] 53. Implement saved-account list, activation, and removal controls.