diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 04016c2..a5f5223 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -68,11 +68,6 @@ fun App() { } LaunchedEffect(Unit) { - viewModel.login() - viewModel.startNotificationHandler() - viewModel.getChatRooms() - - // Collect error events from the ViewModel viewModel.errorEvents.collect { message -> snackbarHostState.showSnackbar(message) } @@ -91,9 +86,6 @@ fun App() { LaunchedEffect(emptySecret) { // Navigate to the home screen if the secret is already set if (emptySecret == false) { - // Get chat rooms - viewModel.getChatRooms() - // Navigate to the home screen navController.navigate(Screen.Home) { popUpTo(Screen.Onboarding) { inclusive = true } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt index c85197e..4d53f16 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt @@ -38,10 +38,10 @@ class NostrForegroundService : Service() { try { val dbDir = File(filesDir, "nostr") dbDir.mkdirs() - // Initialize Nostr client nostr.init(dbDir.absolutePath) - + // Connect to bootstrap relays + nostr.connectBootstrapRelays() // Handle notifications nostr.handleLiteNotifications { event -> if (!isUserInApp()) { diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index 1dfed82..4f0ae20 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -37,10 +37,14 @@ import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberTooltipState import androidx.compose.material3.toShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -78,7 +82,6 @@ fun HomeScreen( val clipboard = LocalClipboard.current val snackbarHostState = LocalSnackbarHostState.current val viewModel = LocalNostrViewModel.current - val scope = rememberCoroutineScope() val currentUser = viewModel.currentUser() ?: return val currentUserProfile = viewModel.getMetadata(currentUser) ?: return @@ -86,10 +89,17 @@ fun HomeScreen( val userProfile by currentUserProfile.collectAsState(initial = null) val chatRooms by viewModel.chatRooms.collectAsState(initial = emptyList()) + val scope = rememberCoroutineScope() val sheetState = rememberModalBottomSheetState() val listState = rememberLazyListState() + val pullToRefreshState = rememberPullToRefreshState() val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } } var showBottomSheet by remember { mutableStateOf(false) } + var isRefreshing by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + viewModel.getChatRooms() + } Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, @@ -165,34 +175,54 @@ fun HomeScreen( color = MaterialTheme.colorScheme.surface, shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), ) { - if (chatRooms.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = "No chats yet", - style = MaterialTheme.typography.titleLargeEmphasized, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = "Your conversations will appear here.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.outline - ) + PullToRefreshBox( + modifier = Modifier.fillMaxSize(), + isRefreshing = isRefreshing, + state = pullToRefreshState, + onRefresh = { + scope.launch { + isRefreshing = true + viewModel.refreshChatRooms() + isRefreshing = false } + }, + indicator = { + PullToRefreshDefaults.LoadingIndicator( + state = pullToRefreshState, + isRefreshing = isRefreshing, + modifier = Modifier.align(Alignment.TopCenter), + ) } - } else { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize() - ) { - items(chatRooms.toList(), key = { it.id }) { room -> - ChatRoom( - room = room, - onClick = { onOpenChat(room.id) } - ) + ) { + if (chatRooms.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "No chats yet", + style = MaterialTheme.typography.titleLargeEmphasized, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "Your conversations will appear here.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline + ) + } + } + } else { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize() + ) { + items(chatRooms.toList(), key = { it.id }) { room -> + ChatRoom( + room = room, + onClick = { onOpenChat(room.id) } + ) + } } } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index ad93f65..a7fc1a5 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -8,6 +8,10 @@ import io.ktor.client.statement.HttpResponse import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import rust.nostr.sdk.AckPolicy import rust.nostr.sdk.Alphabet @@ -57,7 +61,9 @@ object NostrManager { } class Nostr { - private var isInitialized = false + private val _isInitialized = MutableStateFlow(false) + val isInitialized: StateFlow = _isInitialized.asStateFlow() + var client: Client? = null private set var signer: UniversalSigner = UniversalSigner(Keys.generate()) @@ -73,7 +79,7 @@ class Nostr { suspend fun init(dbPath: String) { try { - if (isInitialized) return + if (_isInitialized.value) return // Initialize the logger for nostr client initLogger(LogLevel.DEBUG) @@ -97,33 +103,33 @@ class Nostr { .sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout)) .build() - // Bootstrap relays - client?.addRelay(RelayUrl.parse("wss://relay.damus.io")) - client?.addRelay(RelayUrl.parse("wss://relay.primal.net")) - client?.addRelay(RelayUrl.parse("wss://user.kindpag.es")) - client?.addRelay(RelayUrl.parse("wss://purplepag.es")) - - // Add search relay - client?.addRelay( - url = RelayUrl.parse("wss://antiprimal.net"), - capabilities = RelayCapabilities.read() - ) - - // Indexer relay for NIP-65 discovery - client?.addRelay( - url = RelayUrl.parse("wss://indexer.coracle.social"), - capabilities = RelayCapabilities.gossip() - ) - - // Connect to all bootstrap relays and wait for all connections to be established - client?.connect(Duration.parse("3s")) - - isInitialized = true + _isInitialized.value = true } catch (e: Exception) { throw IllegalStateException("Failed to initialize Nostr client: ${e.message}", e) } } + suspend fun waitUntilInitialized() { + _isInitialized.first { it } + } + + suspend fun connectBootstrapRelays() { + // Bootstrap relays + client?.addRelay(RelayUrl.parse("wss://relay.primal.net")) + client?.addRelay(RelayUrl.parse("wss://user.kindpag.es")) + client?.addRelay(RelayUrl.parse("wss://purplepag.es")) + + + // Indexer relay for NIP-65 discovery + client?.addRelay( + url = RelayUrl.parse("wss://indexer.coracle.social"), + capabilities = RelayCapabilities.gossip() + ) + + // Connect to all bootstrap relays and wait for all connections to be established + client?.connect(Duration.parse("2s")) + } + suspend fun disconnect() { client?.shutdown() } @@ -773,6 +779,13 @@ class Nostr { suspend fun searchByNostr(query: String): List { try { + // Add search relay + val searchRelay = RelayUrl.parse("wss://antiprimal.net") + if (client?.relay(searchRelay) == null) { + client?.addRelay(url = searchRelay, capabilities = RelayCapabilities.read()) + client?.connectRelay(searchRelay) + } + val kinds = listOf(Kind.fromStd(KindStandard.METADATA)) val filter = Filter().kinds(kinds).search(query).limit(10u) val target = diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 036a523..0ae1fbc 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -62,8 +62,10 @@ class NostrViewModel( private val seenPublicKeys = mutableSetOf() init { - startMetadataBatchProcessor() + startNotificationHandler() + startMetadataBatchHandler() getCacheMetadata() + login() } override fun onCleared() { @@ -83,8 +85,35 @@ class NostrViewModel( } } - private fun startMetadataBatchProcessor() { + private fun startNotificationHandler() { viewModelScope.launch { + // Wait until the client is ready + nostr.waitUntilInitialized() + + nostr.handleNotifications( + onMetadataUpdate = { pubkey, metadata -> + updateMetadata(pubkey, metadata) + }, + onContactListUpdate = { contactList -> + _contactList.value = contactList.toSet() + }, + onSubscriptionClose = { + getChatRooms() + }, + onNewMessage = { event -> + viewModelScope.launch { + _newEvents.emit(event) + } + }, + ) + } + } + + private fun startMetadataBatchHandler() { + viewModelScope.launch { + // Wait until the client is ready + nostr.waitUntilInitialized() + val batch = mutableSetOf() val timeout = 500L // 500ms timeout for batching @@ -116,15 +145,56 @@ class NostrViewModel( private fun getCacheMetadata() { viewModelScope.launch { + // Wait until the client is ready + nostr.waitUntilInitialized() + val results = nostr.getAllCacheMetadata() results.forEach { (pubkey, metadata) -> - println("Cache metadata for pubkey $pubkey: $metadata") updateMetadata(pubkey, metadata) seenPublicKeys.add(pubkey) } } } + private fun login() { + viewModelScope.launch { + // Wait until the client is ready + nostr.waitUntilInitialized() + + // Get user's signer secret + val secret = secretStore.get("user_signer") + + // If no secret is found, show onboarding screen + when (secret) { + null -> { + _emptySecret.value = true + return@launch + } + + else -> _emptySecret.value = false + } + + // Handle different signer types + if (secret.startsWith("nsec1")) { + val keys = Keys.parse(secret) + nostr.setSigner(keys) + } else if (secret.startsWith("bunker://")) { + try { + val appKeys = getOrInitAppKeys() + val bunker = NostrConnectUri.parse(secret) + val timeout = Duration.parse("50s") // 50 seconds timeout + val remote = + NostrConnect(uri = bunker, appKeys = appKeys, timeout = timeout, null) + nostr.setSigner(remote) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } else { + throw IllegalArgumentException("Invalid secret format: $secret") + } + } + } + private fun requestMetadata(pubkey: PublicKey) { if (seenPublicKeys.add(pubkey)) { viewModelScope.launch { @@ -145,82 +215,11 @@ class NostrViewModel( return flow.asStateFlow() } - suspend fun login() { - try { - getUserSecret() - } catch (e: Exception) { - showError("Failed to login: ${e.message}") - } - } - - fun startNotificationHandler() { - viewModelScope.launch { - nostr.handleNotifications( - onMetadataUpdate = { pubkey, metadata -> - updateMetadata(pubkey, metadata) - }, - onContactListUpdate = { contactList -> - _contactList.value = contactList.toSet() - }, - onSubscriptionClose = { - getChatRooms() - }, - onNewMessage = { event -> - viewModelScope.launch { - _newEvents.emit(event) - } - }, - ) - } - } - fun currentUser(): PublicKey? { return nostr.signer.currentUser } - fun logout() { - viewModelScope.launch { - _emptySecret.value = true - _chatRooms.value = emptySet() - secretStore.clear("user_signer") - nostr.exit() - } - } - - suspend fun getUserSecret() { - // Get user's signer secret - val secret = secretStore.get("user_signer") - - // If no secret is found, show onboarding screen - when (secret) { - null -> { - _emptySecret.value = true - return - } - - else -> _emptySecret.value = false - } - - // Handle different signer types - if (secret.startsWith("nsec1")) { - val keys = Keys.parse(secret) - nostr.setSigner(keys) - } else if (secret.startsWith("bunker://")) { - try { - val appKeys = getOrInitAppKeys() - val bunker = NostrConnectUri.parse(secret) - val timeout = Duration.parse("50s") // 50 seconds timeout - val remote = NostrConnect(uri = bunker, appKeys = appKeys, timeout = timeout, null) - nostr.setSigner(remote) - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } else { - throw IllegalArgumentException("Invalid secret format: $secret") - } - } - - suspend fun getOrInitAppKeys(): Keys { + private suspend fun getOrInitAppKeys(): Keys { val secret = secretStore.get("app_keys") // If app keys are already stored, use them @@ -348,6 +347,14 @@ class NostrViewModel( } } + suspend fun refreshChatRooms() { + try { + _chatRooms.value = nostr.getChatRooms() ?: emptySet() + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + suspend fun getChatRoomMessages(roomId: Long): List { try { return nostr.getChatRoomMessages(roomId)