app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

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:
Aapp/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/AccountsUiModel.kt | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/AccountsUiModelTest.kt | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdocs/implementation/nostr-runtime-rcld.md | 2+-
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.