chore: merge the develop branch into master #1
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user