diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/MarmotKeyPackageBundleDao.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/MarmotKeyPackageBundleDao.kt index 93c91f77..8102afdd 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/MarmotKeyPackageBundleDao.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/MarmotKeyPackageBundleDao.kt @@ -4,9 +4,13 @@ import androidx.room3.Dao import androidx.room3.Query import androidx.room3.Upsert import at.torch.compose.database.model.MarmotKeyPackageBundle +import com.vitorpamplona.quartz.nip01Core.core.HexKey @Dao interface MarmotKeyPackageBundleDao { + @Query("SELECT * FROM MarmotKeyPackageBundle WHERE publicKey = :publicKey ORDER BY createdAt DESC") + fun getAllMarmotKeyPackageBundles(publicKey: HexKey): List + @Query("SELECT * FROM MarmotKeyPackageBundle WHERE id = :id ORDER BY createdAt DESC") fun getMarmotKeyPackageBundleById(id: String): MarmotKeyPackageBundle? diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/database/repository/DatabaseMarmotRepository.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/database/repository/DatabaseMarmotRepository.kt index 12b9c904..f5bccbb7 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/database/repository/DatabaseMarmotRepository.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/database/repository/DatabaseMarmotRepository.kt @@ -36,7 +36,7 @@ class DatabaseMarmotRepository( ): MarmotRepository { private val logger = Logger.withTag(TAG) - override suspend fun saveMarmotKeyPackageBundle( + override suspend fun publishMarmotKeyPackageBundle( publicKey: HexKey, nsecPassword: String ) { @@ -88,6 +88,9 @@ class DatabaseMarmotRepository( ) } + override suspend fun getMarmotKeyPackageBundles(publicKey: HexKey): List { + return database.marmotKeyPackageBundleDao().getAllMarmotKeyPackageBundles(publicKey) + } private fun generateKeyPackage( identity: ByteArray, diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/repository/MarmotRepository.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/repository/MarmotRepository.kt index b99ca1ba..54aa10ac 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/repository/MarmotRepository.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/repository/MarmotRepository.kt @@ -1,23 +1,30 @@ package at.torch.compose.repository +import at.torch.compose.database.model.MarmotKeyPackageBundle import com.vitorpamplona.quartz.nip01Core.core.HexKey interface MarmotRepository { - suspend fun saveMarmotKeyPackageBundle( + suspend fun publishMarmotKeyPackageBundle( publicKey: HexKey, nsecPassword: String ) + suspend fun getMarmotKeyPackageBundles(publicKey: HexKey): List + companion object { val NO_OP_MARMOT_KEY_PACKAGE_BUNDLE = object: MarmotRepository { - override suspend fun saveMarmotKeyPackageBundle( + override suspend fun publishMarmotKeyPackageBundle( publicKey: HexKey, nsecPassword: String ) { TODO("Not yet implemented") } + override suspend fun getMarmotKeyPackageBundles(publicKey: HexKey): List { + return emptyList() + } + } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/ActiveProfileScreen.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/ActiveProfileScreen.kt index b1793dca..a3315fd4 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/ActiveProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/ActiveProfileScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Bento import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Hub import androidx.compose.material.icons.filled.ImportExport @@ -19,6 +20,7 @@ import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.Logout import androidx.compose.material.icons.filled.QrCode import androidx.compose.material.icons.filled.SwitchVideo +import androidx.compose.material.icons.filled.Widgets import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @@ -42,6 +44,7 @@ import at.torch.compose.database.model.intermdiate.LocalNostrEvent import at.torch.compose.extensions.hexToNpubHrp import at.torch.compose.repository.NostrRepository import at.torch.compose.ui.composable.navigation.routes.ImplementationPendingRoute +import at.torch.compose.ui.composable.navigation.routes.KeyPackageManagementRoute import at.torch.compose.ui.composable.navigation.routes.Route import at.torch.compose.ui.composable.navigation.routes.ShareProfileRoute import at.torch.compose.ui.composable.widgets.LoadingDataIndicator @@ -212,6 +215,33 @@ fun ActiveProfileScreen( } } + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + KeyPackageManagementRoute( + activeUserPublicKey = activeUserPublicKey, + nostrEventId = nostrEventId + ) + ) + } + ) { + + Icon( + Icons.Default.Bento, + contentDescription = "Key Packages" + ) + + Spacer( + modifier = Modifier.width(10.dp) + ) + + Text( + "Key Package Management" + ) + } + } + item { TextButton( diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/KeyPackageManagementScreen.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/KeyPackageManagementScreen.kt new file mode 100644 index 00000000..776f1649 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/KeyPackageManagementScreen.kt @@ -0,0 +1,216 @@ +package at.torch.compose.ui.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Bento +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Hub +import androidx.compose.material.icons.filled.ImportExport +import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.LockReset +import androidx.compose.material.icons.filled.Logout +import androidx.compose.material.icons.filled.PrivateConnectivity +import androidx.compose.material.icons.filled.Publish +import androidx.compose.material.icons.filled.QrCode +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import at.torch.compose.extensions.hexToNpubHrp +import at.torch.compose.extensions.toFormattedTimeAndDateString +import at.torch.compose.repository.MarmotRepository +import at.torch.compose.repository.NostrRepository +import at.torch.compose.ui.composable.navigation.routes.ImplementationPendingRoute +import at.torch.compose.ui.composable.navigation.routes.Route +import at.torch.compose.ui.composable.navigation.routes.ShareProfileRoute +import at.torch.compose.ui.composable.widgets.LoadingDataIndicator +import at.torch.compose.ui.composable.widgets.profile.ProfileAvatar +import at.torch.compose.ui.view.model.KeyPackageManagementViewModel +import at.torch.compose.ui.view.state.KeyPackageManagementUIState +import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +fun KeyPackageManagementScreen( + initialActiveProfileUIState: KeyPackageManagementUIState = KeyPackageManagementUIState.Loading, + activeUserPublicKey: HexKey, + nostrEventId: HexKey, + onNavigateBack: () -> Unit, + onNavigateToRoute: (Route) -> Unit, + marmotRepository: MarmotRepository +) { + + val keyPackageManagementViewModel: KeyPackageManagementViewModel = viewModel( + factory = KeyPackageManagementViewModel.factory( + nostrEventId = nostrEventId, + initialActiveProfileUIState = initialActiveProfileUIState, + marmotRepository = marmotRepository, + activeUserPublicKey = activeUserPublicKey + ), + ) + + when(val keyPackageManagementUIState = keyPackageManagementViewModel.keyPackageManagementUIState) { + KeyPackageManagementUIState.Error -> { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("Something went wrong") + } + } + is KeyPackageManagementUIState.Loaded -> { + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = "Key Package Management" + ) + }, + navigationIcon = { + IconButton( + onClick = { + onNavigateBack.invoke() + } + ) { + Icon( + Icons.Default.ArrowBack, + contentDescription = "Back" + ) + } + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding) + ) { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + item { + Button( + onClick = { + keyPackageManagementViewModel.publishNewKeyPackage() + } + ) { + Icon( + Icons.Default.LockReset, + contentDescription = "Publish new key package" + ) + + Spacer( + modifier = Modifier.width(10.dp) + ) + + Text( + text = "Publish New Key Package" + ) + } + } + item { + Text( + "${keyPackageManagementUIState.keyPackageBundles.size} Key Packages" + ) + } + + items( + items = keyPackageManagementUIState.keyPackageBundles + ) { marmotKeyPackageBundle -> + + Row { + Text( + text = marmotKeyPackageBundle.id, + style = MaterialTheme.typography.bodySmall + ) + + Text( + text = marmotKeyPackageBundle.createdAt.toFormattedTimeAndDateString(), + style = MaterialTheme.typography.labelSmall + ) + + TextButton( + onClick = { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Delete Key Package") + ) + } + ) { + Text( + "Delete" + ) + } + } + } + } + } + } + } + KeyPackageManagementUIState.Loading -> { + LoadingDataIndicator() + + LaunchedEffect(true) { + keyPackageManagementViewModel.loadNostrFeed() + } + } + KeyPackageManagementUIState.NotFound -> { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("We couldn't find the local profile. Please try again later.") + } + } + } +} + +@Preview +@Composable +private fun UnannouncedProfileScreenPreview() { + _root_ide_package_.at.torch.compose.ui.theme.TorchTheme { + Surface( + modifier = Modifier.fillMaxSize() + ) { + KeyPackageManagementScreen( + initialActiveProfileUIState = KeyPackageManagementUIState.Loaded( + keyPackageBundles = emptyList() + ), + activeUserPublicKey = "84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240", + nostrEventId = "", + marmotRepository = MarmotRepository.NO_OP_MARMOT_KEY_PACKAGE_BUNDLE, + onNavigateToRoute = {}, + onNavigateBack = {} + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/navigation/TorchNavHost.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/navigation/TorchNavHost.kt index c1e085f9..0a5b4fc9 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/navigation/TorchNavHost.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/navigation/TorchNavHost.kt @@ -17,11 +17,13 @@ import at.torch.compose.ui.composable.ActiveProfileScreen import at.torch.compose.ui.composable.ChatRoomCreationScreen import at.torch.compose.ui.composable.ChatRoomDetailScreen import at.torch.compose.ui.composable.HomeScreen +import at.torch.compose.ui.composable.KeyPackageManagementScreen import at.torch.compose.ui.composable.NostrEventDetailScreen import at.torch.compose.ui.composable.ShareProfileScreen import at.torch.compose.ui.composable.UnsyncedProfileScreen import at.torch.compose.ui.composable.navigation.routes.ActiveProfileRoute import at.torch.compose.ui.composable.navigation.routes.ChatRoomCreationRoute +import at.torch.compose.ui.composable.navigation.routes.KeyPackageManagementRoute import at.torch.compose.ui.composable.navigation.routes.LoadingRoute import at.torch.compose.ui.composable.navigation.routes.NostrEventDetailRoute import at.torch.compose.ui.composable.navigation.routes.ShareProfileRoute @@ -490,6 +492,23 @@ fun TorchNavHost( } ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + KeyPackageManagementScreen( + activeUserPublicKey = route.activeUserPublicKey, + nostrEventId = route.nostrEventId, + marmotRepository = databaseMarmotRepository, + onNavigateBack = { + navController.popBackStack() + }, + onNavigateToRoute = { route -> + navController.navigate( + route = route + ) + } + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/navigation/routes/KeyPackageManagementRoute.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/navigation/routes/KeyPackageManagementRoute.kt new file mode 100755 index 00000000..c26d0d02 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/navigation/routes/KeyPackageManagementRoute.kt @@ -0,0 +1,9 @@ +package at.torch.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +@Serializable +data class KeyPackageManagementRoute( + val activeUserPublicKey: String, + val nostrEventId: String, +): Route() \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/CreateProfileViewModel.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/CreateProfileViewModel.kt index c9045567..74130058 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/CreateProfileViewModel.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/CreateProfileViewModel.kt @@ -112,7 +112,7 @@ class CreateProfileViewModel( biography = createProfileFormState.biographyField.textFieldState.text.toString() ) - marmotRepository.saveMarmotKeyPackageBundle( + marmotRepository.publishMarmotKeyPackageBundle( publicKey = pubkey, nsecPassword = localKeyManager.nsecPassword() ) diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/KeyPackageManagementViewModel.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/KeyPackageManagementViewModel.kt new file mode 100755 index 00000000..d19a26e4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/KeyPackageManagementViewModel.kt @@ -0,0 +1,72 @@ +package at.torch.compose.ui.view.model + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import at.torch.compose.repository.MarmotRepository +import at.torch.compose.ui.view.state.KeyPackageManagementUIState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch + +class KeyPackageManagementViewModel( + val activeUserPublicKey: HexKey, + val nostrEventId: HexKey, + initialActiveProfileUIState: KeyPackageManagementUIState, + val marmotRepository: MarmotRepository +): ViewModel() { + var keyPackageManagementUIState: KeyPackageManagementUIState by mutableStateOf(initialActiveProfileUIState) + private set + + fun retryLoad() { + keyPackageManagementUIState = KeyPackageManagementUIState.Loading + loadNostrFeed() + } + + fun loadNostrFeed() { + viewModelScope.launch(Dispatchers.IO) { + keyPackageManagementUIState = KeyPackageManagementUIState.Loaded( + keyPackageBundles = marmotRepository.getMarmotKeyPackageBundles(activeUserPublicKey) + ) + } + } + + fun publishNewKeyPackage() { + viewModelScope.launch(Dispatchers.IO) { + marmotRepository.publishMarmotKeyPackageBundle( + publicKey = activeUserPublicKey, + nsecPassword = "" // TODO: Implement nsecPassword logic... + ) + + keyPackageManagementUIState = KeyPackageManagementUIState.Loaded( + keyPackageBundles = marmotRepository.getMarmotKeyPackageBundles(activeUserPublicKey) + ) + } + } + + companion object { + const val TAG = "KeyPackageManagementViewModel" + + fun factory( + nostrEventId: HexKey, + activeUserPublicKey: HexKey, + initialActiveProfileUIState: KeyPackageManagementUIState = KeyPackageManagementUIState.Loading, + marmotRepository: MarmotRepository + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + KeyPackageManagementViewModel( + nostrEventId = nostrEventId, + initialActiveProfileUIState = initialActiveProfileUIState, + marmotRepository = marmotRepository, + activeUserPublicKey = activeUserPublicKey + ) + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/state/KeyPackageManagementUIState.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/state/KeyPackageManagementUIState.kt new file mode 100755 index 00000000..3a551b3a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/state/KeyPackageManagementUIState.kt @@ -0,0 +1,14 @@ +package at.torch.compose.ui.view.state + +import at.torch.compose.database.model.MarmotKeyPackageBundle + +sealed interface KeyPackageManagementUIState { + data class Loaded( + val keyPackageBundles: List + ): KeyPackageManagementUIState + + data object Error: KeyPackageManagementUIState + data object NotFound: KeyPackageManagementUIState + data object Loading: KeyPackageManagementUIState +} +