refactor
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Boolean> = _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<PublicKey> {
|
||||
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 =
|
||||
|
||||
@@ -62,8 +62,10 @@ class NostrViewModel(
|
||||
private val seenPublicKeys = mutableSetOf<PublicKey>()
|
||||
|
||||
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<PublicKey>()
|
||||
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<UnsignedEvent> {
|
||||
try {
|
||||
return nostr.getChatRoomMessages(roomId)
|
||||
|
||||
Reference in New Issue
Block a user