chore: merge the develop branch into master #1

Merged
reya merged 43 commits from develop into master 2026-05-23 00:50:13 +00:00
5 changed files with 178 additions and 136 deletions
Showing only changes of commit fd64998fd8 - Show all commits

View File

@@ -68,11 +68,6 @@ fun App() {
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.login()
viewModel.startNotificationHandler()
viewModel.getChatRooms()
// Collect error events from the ViewModel
viewModel.errorEvents.collect { message -> viewModel.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message) snackbarHostState.showSnackbar(message)
} }
@@ -91,9 +86,6 @@ fun App() {
LaunchedEffect(emptySecret) { LaunchedEffect(emptySecret) {
// Navigate to the home screen if the secret is already set // Navigate to the home screen if the secret is already set
if (emptySecret == false) { if (emptySecret == false) {
// Get chat rooms
viewModel.getChatRooms()
// Navigate to the home screen
navController.navigate(Screen.Home) { navController.navigate(Screen.Home) {
popUpTo(Screen.Onboarding) { inclusive = true } popUpTo(Screen.Onboarding) { inclusive = true }
} }

View File

@@ -38,10 +38,10 @@ class NostrForegroundService : Service() {
try { try {
val dbDir = File(filesDir, "nostr") val dbDir = File(filesDir, "nostr")
dbDir.mkdirs() dbDir.mkdirs()
// Initialize Nostr client // Initialize Nostr client
nostr.init(dbDir.absolutePath) nostr.init(dbDir.absolutePath)
// Connect to bootstrap relays
nostr.connectBootstrapRelays()
// Handle notifications // Handle notifications
nostr.handleLiteNotifications { event -> nostr.handleLiteNotifications { event ->
if (!isUserInApp()) { if (!isUserInApp()) {

View File

@@ -37,10 +37,14 @@ import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults 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.rememberModalBottomSheetState
import androidx.compose.material3.rememberTooltipState import androidx.compose.material3.rememberTooltipState
import androidx.compose.material3.toShape import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -78,7 +82,6 @@ fun HomeScreen(
val clipboard = LocalClipboard.current val clipboard = LocalClipboard.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val viewModel = LocalNostrViewModel.current val viewModel = LocalNostrViewModel.current
val scope = rememberCoroutineScope()
val currentUser = viewModel.currentUser() ?: return val currentUser = viewModel.currentUser() ?: return
val currentUserProfile = viewModel.getMetadata(currentUser) ?: return val currentUserProfile = viewModel.getMetadata(currentUser) ?: return
@@ -86,10 +89,17 @@ fun HomeScreen(
val userProfile by currentUserProfile.collectAsState(initial = null) val userProfile by currentUserProfile.collectAsState(initial = null)
val chatRooms by viewModel.chatRooms.collectAsState(initial = emptyList()) val chatRooms by viewModel.chatRooms.collectAsState(initial = emptyList())
val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState() val sheetState = rememberModalBottomSheetState()
val listState = rememberLazyListState() val listState = rememberLazyListState()
val pullToRefreshState = rememberPullToRefreshState()
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } } val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
var isRefreshing by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
viewModel.getChatRooms()
}
Scaffold( Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
@@ -164,6 +174,25 @@ fun HomeScreen(
.padding(top = innerPadding.calculateTopPadding()), .padding(top = innerPadding.calculateTopPadding()),
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) {
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),
)
}
) { ) {
if (chatRooms.isEmpty()) { if (chatRooms.isEmpty()) {
Box( Box(
@@ -196,6 +225,7 @@ fun HomeScreen(
} }
} }
} }
}
if (showBottomSheet) { if (showBottomSheet) {
ModalBottomSheet( ModalBottomSheet(

View File

@@ -8,6 +8,10 @@ import io.ktor.client.statement.HttpResponse
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay 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 kotlinx.coroutines.launch
import rust.nostr.sdk.AckPolicy import rust.nostr.sdk.AckPolicy
import rust.nostr.sdk.Alphabet import rust.nostr.sdk.Alphabet
@@ -57,7 +61,9 @@ object NostrManager {
} }
class Nostr { class Nostr {
private var isInitialized = false private val _isInitialized = MutableStateFlow(false)
val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow()
var client: Client? = null var client: Client? = null
private set private set
var signer: UniversalSigner = UniversalSigner(Keys.generate()) var signer: UniversalSigner = UniversalSigner(Keys.generate())
@@ -73,7 +79,7 @@ class Nostr {
suspend fun init(dbPath: String) { suspend fun init(dbPath: String) {
try { try {
if (isInitialized) return if (_isInitialized.value) return
// Initialize the logger for nostr client // Initialize the logger for nostr client
initLogger(LogLevel.DEBUG) initLogger(LogLevel.DEBUG)
@@ -97,17 +103,22 @@ class Nostr {
.sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout)) .sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout))
.build() .build()
_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 // Bootstrap relays
client?.addRelay(RelayUrl.parse("wss://relay.damus.io"))
client?.addRelay(RelayUrl.parse("wss://relay.primal.net")) client?.addRelay(RelayUrl.parse("wss://relay.primal.net"))
client?.addRelay(RelayUrl.parse("wss://user.kindpag.es")) client?.addRelay(RelayUrl.parse("wss://user.kindpag.es"))
client?.addRelay(RelayUrl.parse("wss://purplepag.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 // Indexer relay for NIP-65 discovery
client?.addRelay( client?.addRelay(
@@ -116,12 +127,7 @@ class Nostr {
) )
// Connect to all bootstrap relays and wait for all connections to be established // Connect to all bootstrap relays and wait for all connections to be established
client?.connect(Duration.parse("3s")) client?.connect(Duration.parse("2s"))
isInitialized = true
} catch (e: Exception) {
throw IllegalStateException("Failed to initialize Nostr client: ${e.message}", e)
}
} }
suspend fun disconnect() { suspend fun disconnect() {
@@ -773,6 +779,13 @@ class Nostr {
suspend fun searchByNostr(query: String): List<PublicKey> { suspend fun searchByNostr(query: String): List<PublicKey> {
try { 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 kinds = listOf(Kind.fromStd(KindStandard.METADATA))
val filter = Filter().kinds(kinds).search(query).limit(10u) val filter = Filter().kinds(kinds).search(query).limit(10u)
val target = val target =

View File

@@ -62,8 +62,10 @@ class NostrViewModel(
private val seenPublicKeys = mutableSetOf<PublicKey>() private val seenPublicKeys = mutableSetOf<PublicKey>()
init { init {
startMetadataBatchProcessor() startNotificationHandler()
startMetadataBatchHandler()
getCacheMetadata() getCacheMetadata()
login()
} }
override fun onCleared() { override fun onCleared() {
@@ -83,8 +85,35 @@ class NostrViewModel(
} }
} }
private fun startMetadataBatchProcessor() { private fun startNotificationHandler() {
viewModelScope.launch { 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<PublicKey>() val batch = mutableSetOf<PublicKey>()
val timeout = 500L // 500ms timeout for batching val timeout = 500L // 500ms timeout for batching
@@ -116,15 +145,56 @@ class NostrViewModel(
private fun getCacheMetadata() { private fun getCacheMetadata() {
viewModelScope.launch { viewModelScope.launch {
// Wait until the client is ready
nostr.waitUntilInitialized()
val results = nostr.getAllCacheMetadata() val results = nostr.getAllCacheMetadata()
results.forEach { (pubkey, metadata) -> results.forEach { (pubkey, metadata) ->
println("Cache metadata for pubkey $pubkey: $metadata")
updateMetadata(pubkey, metadata) updateMetadata(pubkey, metadata)
seenPublicKeys.add(pubkey) 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) { private fun requestMetadata(pubkey: PublicKey) {
if (seenPublicKeys.add(pubkey)) { if (seenPublicKeys.add(pubkey)) {
viewModelScope.launch { viewModelScope.launch {
@@ -145,82 +215,11 @@ class NostrViewModel(
return flow.asStateFlow() 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? { fun currentUser(): PublicKey? {
return nostr.signer.currentUser return nostr.signer.currentUser
} }
fun logout() { private suspend fun getOrInitAppKeys(): Keys {
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 {
val secret = secretStore.get("app_keys") val secret = secretStore.get("app_keys")
// If app keys are already stored, use them // 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<UnsignedEvent> { suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> {
try { try {
return nostr.getChatRoomMessages(roomId) return nostr.getChatRoomMessages(roomId)