This commit is contained in:
2026-05-09 09:07:27 +07:00
parent 5c31f7a0d6
commit e824aa7e16
8 changed files with 222 additions and 81 deletions

View File

@@ -111,6 +111,7 @@ fun App(dbPath: String) {
ImportScreen( ImportScreen(
isLoading = isCreating, isLoading = isCreating,
onBack = { navController.popBackStack() },
onSave = { secret -> onSave = { secret ->
viewModel.importIdentity(secret) viewModel.importIdentity(secret)
} }

View File

@@ -5,14 +5,25 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -21,19 +32,52 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalSnackbarHostState
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun ImportScreen( fun ImportScreen(
isLoading: Boolean, isLoading: Boolean,
onBack: () -> Unit,
onSave: (secret: String) -> Unit onSave: (secret: String) -> Unit
) { ) {
val snackbarHostState = LocalSnackbarHostState.current
var secret by remember { mutableStateOf("") } var secret by remember { mutableStateOf("") }
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { Text("Import") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
)
)
},
content = { innerPadding ->
Surface(
modifier = Modifier
.fillMaxSize()
.padding(top = innerPadding.calculateTopPadding()),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(16.dp) .padding(24.dp)
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
@@ -50,14 +94,22 @@ fun ImportScreen(
onClick = { onClick = {
onSave(secret) onSave(secret)
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.height(ButtonDefaults.LargeContainerHeight),
enabled = secret.isNotBlank() && !isLoading, enabled = secret.isNotBlank() && !isLoading,
) { ) {
if (isLoading) { if (isLoading) {
LoadingIndicator() LoadingIndicator()
} else { } else {
Text("Save & Continue") Text(
text = "Save & Continue",
style = MaterialTheme.typography.titleLargeEmphasized,
)
} }
} }
} }
} }
}
)
}

View File

@@ -75,7 +75,7 @@ fun NewIdentityScreen(
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon( Icon(
painter = painterResource(Res.drawable.ic_arrow_back), painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "User" contentDescription = "Back"
) )
} }
}, },

View File

@@ -15,6 +15,7 @@ junit = "4.13.2"
kotlin = "2.3.20" kotlin = "2.3.20"
kotlinx-serialization = "1.8.0" kotlinx-serialization = "1.8.0"
material3 = "1.10.0-alpha05" material3 = "1.10.0-alpha05"
ktor = "3.4.3"
[libraries] [libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
@@ -36,6 +37,10 @@ compose-material3 = { module = "org.jetbrains.compose.material3:material3", vers
compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" } compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" }
compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" } compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" }
compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" } compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
[plugins] [plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" } androidApplication = { id = "com.android.application", version.ref = "agp" }

View File

@@ -26,7 +26,15 @@ kotlin {
commonMain.dependencies { commonMain.dependencies {
implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0") implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
implementation("su.reya:nostr-sdk-kmp:0.1.2") implementation("su.reya:nostr-sdk-kmp:0.1.5")
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.websockets)
}
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
} }
commonTest.dependencies { commonTest.dependencies {
implementation(libs.kotlin.test) implementation(libs.kotlin.test)

View File

@@ -0,0 +1,75 @@
package su.reya.coop
import io.ktor.client.HttpClient
import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession
import io.ktor.client.plugins.websocket.webSocketSession
import io.ktor.client.request.url
import io.ktor.websocket.Frame
import io.ktor.websocket.close
import io.ktor.websocket.readBytes
import io.ktor.websocket.readText
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import rust.nostr.sdk.ConnectionMode
import rust.nostr.sdk.CustomWebSocketTransport
import rust.nostr.sdk.WebSocketAdapter
import rust.nostr.sdk.WebSocketAdapterWrapper
import rust.nostr.sdk.WebSocketMessage
class KtorWebSocketAdapter(
private val client: HttpClient,
private val session: DefaultClientWebSocketSession
) : WebSocketAdapter {
override suspend fun send(msg: WebSocketMessage) {
try {
when (msg) {
is WebSocketMessage.Text -> session.send(Frame.Text(msg.text))
is WebSocketMessage.Binary -> session.send(Frame.Binary(true, msg.bytes))
is WebSocketMessage.Ping -> session.send(Frame.Ping(msg.bytes))
is WebSocketMessage.Pong -> session.send(Frame.Pong(msg.bytes))
else -> {}
}
} catch (e: Exception) {
println("Attempted to send on a closed WebSocket: ${e.message}")
throw e
}
}
override suspend fun recv(): WebSocketMessage? {
return try {
when (val frame = session.incoming.receive()) {
is Frame.Text -> WebSocketMessage.Text(frame.readText())
is Frame.Binary -> WebSocketMessage.Binary(frame.readBytes())
is Frame.Ping -> WebSocketMessage.Ping(frame.readBytes())
is Frame.Pong -> WebSocketMessage.Pong(frame.readBytes())
else -> null
}
} catch (e: ClosedReceiveChannelException) {
null
} catch (e: Exception) {
throw e
}
}
override suspend fun closeConnection() {
session.cancel()
session.close()
}
}
class CoopWebSocketClient(private val httpClient: HttpClient) : CustomWebSocketTransport {
override fun supportPing(): Boolean = false
override suspend fun connect(url: String, mode: ConnectionMode): WebSocketAdapterWrapper {
try {
val session = httpClient.webSocketSession {
url(url)
}
val adapter = KtorWebSocketAdapter(httpClient, session)
return WebSocketAdapterWrapper(adapter)
} catch (e: Exception) {
throw e
}
}
}

View File

@@ -1,5 +1,7 @@
package su.reya.coop package su.reya.coop
import io.ktor.client.HttpClient
import io.ktor.client.plugins.websocket.WebSockets
import rust.nostr.sdk.Client import rust.nostr.sdk.Client
import rust.nostr.sdk.ClientBuilder import rust.nostr.sdk.ClientBuilder
import rust.nostr.sdk.ClientNotification import rust.nostr.sdk.ClientNotification
@@ -12,6 +14,7 @@ import rust.nostr.sdk.GossipConfig
import rust.nostr.sdk.Keys import rust.nostr.sdk.Keys
import rust.nostr.sdk.Kind import rust.nostr.sdk.Kind
import rust.nostr.sdk.KindStandard import rust.nostr.sdk.KindStandard
import rust.nostr.sdk.LogLevel
import rust.nostr.sdk.Metadata import rust.nostr.sdk.Metadata
import rust.nostr.sdk.MetadataRecord import rust.nostr.sdk.MetadataRecord
import rust.nostr.sdk.NostrConnect import rust.nostr.sdk.NostrConnect
@@ -32,6 +35,7 @@ import rust.nostr.sdk.Timestamp
import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnsignedEvent
import rust.nostr.sdk.UnwrappedGift import rust.nostr.sdk.UnwrappedGift
import rust.nostr.sdk.extractMessagingRelayList import rust.nostr.sdk.extractMessagingRelayList
import rust.nostr.sdk.initLogger
import kotlin.time.Duration import kotlin.time.Duration
class Nostr { class Nostr {
@@ -47,39 +51,37 @@ class Nostr {
private set private set
suspend fun init(dbPath: String) { suspend fun init(dbPath: String) {
try {
// Initialize the logger for nostr client
initLogger(LogLevel.DEBUG)
val lmdb = NostrDatabase.lmdb(dbPath) val lmdb = NostrDatabase.lmdb(dbPath)
val gossip = NostrGossip.inMemory() val gossip = NostrGossip.inMemory()
val idleTimeout = Duration.parse("5m") val idleTimeout = Duration.parse("5m")
val httpClient = HttpClient {
install(WebSockets)
}
client = client =
ClientBuilder() ClientBuilder()
.websocketTransport(CoopWebSocketClient(httpClient))
.database(lmdb) .database(lmdb)
.gossip(gossip) .gossip(gossip)
.gossipConfig(GossipConfig().noBackgroundRefresh()) .gossipConfig(GossipConfig().noBackgroundRefresh())
.maxRelays(20u)
.verifySubscriptions(false) .verifySubscriptions(false)
.automaticAuthentication(false) .automaticAuthentication(false)
.sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout)) .sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout))
.build() .build()
}
suspend fun connect() { client?.addRelay(RelayUrl.parse("wss://relay.primal.net"))
try { client?.addRelay(RelayUrl.parse("wss://user.kindpag.es"))
client?.addRelay(
url = RelayUrl.parse("wss://relay.primal.net"),
capabilities = RelayCapabilities.none()
)
client?.addRelay(
url = RelayUrl.parse("wss://user.kindpag.es"),
capabilities = RelayCapabilities.none()
)
client?.addRelay( client?.addRelay(
url = RelayUrl.parse("wss://indexer.coracle.social"), url = RelayUrl.parse("wss://indexer.coracle.social"),
capabilities = RelayCapabilities.gossip() capabilities = RelayCapabilities.gossip()
) )
client?.connect() client?.connect(Duration.parse("10s"))
} catch (e: Exception) { } catch (e: Exception) {
println("Failed to connect to relays: ${e.message}") println("Failed to initialize client: ${e.message}")
} }
} }
@@ -98,6 +100,8 @@ class Nostr {
try { try {
signer = NostrSigner.keys(keys) signer = NostrSigner.keys(keys)
userPubkey = signer?.getPublicKey() userPubkey = signer?.getPublicKey()
// Fetch metadata for current user
getUserMetadata() getUserMetadata()
} catch (e: Exception) { } catch (e: Exception) {
println("Failed to set signer: ${e.message}") println("Failed to set signer: ${e.message}")
@@ -108,6 +112,8 @@ class Nostr {
try { try {
signer = NostrSigner.nostrConnect(remote) signer = NostrSigner.nostrConnect(remote)
userPubkey = signer?.getPublicKey() userPubkey = signer?.getPublicKey()
// Fetch metadata for current user
getUserMetadata() getUserMetadata()
} catch (e: Exception) { } catch (e: Exception) {
println("Failed to set remote signer: ${e.message}") println("Failed to set remote signer: ${e.message}")
@@ -123,19 +129,17 @@ class Nostr {
} }
suspend fun getUserMetadata() { suspend fun getUserMetadata() {
val userPubkey = signer?.getPublicKey() ?: return
// Get the latest metadata event // Get the latest metadata event
val metadataFilter = val metadataFilter =
Filter().author(userPubkey).limit(1u).kind(Kind.fromStd(KindStandard.METADATA)) Filter().author(userPubkey!!).limit(1u).kind(Kind.fromStd(KindStandard.METADATA))
// Get the latest contact list event // Get the latest contact list event
val contactFilter = val contactFilter =
Filter().author(userPubkey).limit(1u).kind(Kind.fromStd(KindStandard.CONTACT_LIST)) Filter().author(userPubkey!!).limit(1u).kind(Kind.fromStd(KindStandard.CONTACT_LIST))
// Get the latest messaging relay list event // Get the latest messaging relay list event
val msgRelayFilter = val msgRelayFilter =
Filter().author(userPubkey).limit(1u).kind(Kind.fromStd(KindStandard.INBOX_RELAYS)) Filter().author(userPubkey!!).limit(1u).kind(Kind.fromStd(KindStandard.INBOX_RELAYS))
// Construct a target that includes all filters // Construct a target that includes all filters
val target = ReqTarget.auto(listOf(metadataFilter, contactFilter, msgRelayFilter)) val target = ReqTarget.auto(listOf(metadataFilter, contactFilter, msgRelayFilter))
@@ -170,11 +174,11 @@ class Nostr {
suspend fun handleNotifications(onMetadataUpdate: (PublicKey, Metadata) -> Unit) { suspend fun handleNotifications(onMetadataUpdate: (PublicKey, Metadata) -> Unit) {
val now = Timestamp.now() val now = Timestamp.now()
val notifications = client?.notifications()
val processedEvent = mutableSetOf<EventId>() val processedEvent = mutableSetOf<EventId>()
val notifications = client?.notifications() ?: return
while (true) { while (true) {
val notification = notifications?.next() ?: break val notification = notifications.next() ?: continue
when (notification) { when (notification) {
is ClientNotification.Message -> { is ClientNotification.Message -> {
@@ -189,7 +193,7 @@ class Nostr {
if (processedEvent.contains(event.id())) continue if (processedEvent.contains(event.id())) continue
processedEvent.add(event.id()) processedEvent.add(event.id())
if (event.kind().asStd() == KindStandard.METADATA) { if (event.kind().asStd()?.equals(KindStandard.METADATA) == true) {
try { try {
val metadata = Metadata.fromJson(event.content()) val metadata = Metadata.fromJson(event.content())
onMetadataUpdate(event.author(), metadata) onMetadataUpdate(event.author(), metadata)
@@ -198,13 +202,13 @@ class Nostr {
} }
} }
if (event.kind().asStd() == KindStandard.INBOX_RELAYS) { if (event.kind().asStd()?.equals(KindStandard.INBOX_RELAYS) == true) {
if (isSignedByUser(event = event)) { if (isSignedByUser(event = event)) {
getUserMessages(msgRelayList = event) getUserMessages(msgRelayList = event)
} }
} }
if (event.kind().asStd() == KindStandard.GIFT_WRAP) { if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) {
try { try {
val rumor = extractRumor(event) val rumor = extractRumor(event)
// TODO: Handle rumor // TODO: Handle rumor

View File

@@ -101,23 +101,19 @@ class NostrViewModel(
} }
fun getUserProfile(): StateFlow<Metadata?> { fun getUserProfile(): StateFlow<Metadata?> {
return getMetadata(nostr.userPubkey!!) return nostr.userPubkey?.let { getMetadata(it) } ?: MutableStateFlow(null).asStateFlow()
} }
fun initAndConnect(dbPath: String) { suspend fun initAndConnect(dbPath: String) {
viewModelScope.launch {
try { try {
// Initialize nostr client // Initialize nostr client
nostr.init(dbPath) nostr.init(dbPath)
// Connect to bootstrap relays
nostr.connect()
// Get user's secret // Get user's secret
getUserSecret() getUserSecret()
} catch (e: Exception) { } catch (e: Exception) {
showError("Failed to initialize Nostr: ${e.message}") showError("Failed to initialize Nostr: ${e.message}")
} }
} }
}
fun startNotificationHandler() { fun startNotificationHandler() {
viewModelScope.launch { viewModelScope.launch {