Compare commits
5 Commits
master
...
9a8d3c06fa
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a8d3c06fa | |||
| 5970acc88b | |||
| 8ea66d1769 | |||
| 38b704fe18 | |||
| e0701504aa |
@@ -34,7 +34,7 @@ import androidx.navigation3.runtime.entryProvider
|
|||||||
import androidx.navigation3.runtime.rememberNavBackStack
|
import androidx.navigation3.runtime.rememberNavBackStack
|
||||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
import su.reya.coop.repository.ErrorRepository
|
import kotlinx.coroutines.launch
|
||||||
import su.reya.coop.screens.chat.ChatScreen
|
import su.reya.coop.screens.chat.ChatScreen
|
||||||
import su.reya.coop.screens.ContactListScreen
|
import su.reya.coop.screens.ContactListScreen
|
||||||
import su.reya.coop.screens.HomeScreen
|
import su.reya.coop.screens.HomeScreen
|
||||||
@@ -118,10 +118,22 @@ fun App(
|
|||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
ErrorRepository.errors.collect { message ->
|
launch {
|
||||||
|
authViewModel.errorEvents.collect { message ->
|
||||||
snackbarHostState.showSnackbar(message)
|
snackbarHostState.showSnackbar(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
launch {
|
||||||
|
chatViewModel.errorEvents.collect { message ->
|
||||||
|
snackbarHostState.showSnackbar(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
launch {
|
||||||
|
nostrViewModel.errorEvents.collect { message ->
|
||||||
|
snackbarHostState.showSnackbar(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(activity) {
|
LaunchedEffect(activity) {
|
||||||
activity?.let {
|
activity?.let {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import su.reya.coop.nostr.NostrManager
|
import su.reya.coop.nostr.NostrManager
|
||||||
|
import su.reya.coop.repository.MediaRepository
|
||||||
import su.reya.coop.viewmodel.AuthViewModel
|
import su.reya.coop.viewmodel.AuthViewModel
|
||||||
import su.reya.coop.viewmodel.ChatViewModel
|
import su.reya.coop.viewmodel.ChatViewModel
|
||||||
import su.reya.coop.viewmodel.NostrViewModel
|
import su.reya.coop.viewmodel.NostrViewModel
|
||||||
@@ -26,12 +27,13 @@ class MainActivity : ComponentActivity() {
|
|||||||
private val factory by lazy {
|
private val factory by lazy {
|
||||||
object : ViewModelProvider.Factory {
|
object : ViewModelProvider.Factory {
|
||||||
private val storage = AppStore(this@MainActivity)
|
private val storage = AppStore(this@MainActivity)
|
||||||
private val nostrViewModel = NostrViewModel(NostrManager.instance)
|
private val mediaRepository = MediaRepository()
|
||||||
private val chatViewModel = ChatViewModel(NostrManager.instance)
|
private val nostrViewModel = NostrViewModel(NostrManager.instance, mediaRepository)
|
||||||
|
private val chatViewModel = ChatViewModel(NostrManager.instance, mediaRepository)
|
||||||
private val androidSigner =
|
private val androidSigner =
|
||||||
AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
|
AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
|
||||||
private val authViewModel =
|
private val authViewModel =
|
||||||
AuthViewModel(NostrManager.instance, storage, androidSigner)
|
AuthViewModel(NostrManager.instance, storage, mediaRepository, androidSigner)
|
||||||
|
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
return when {
|
return when {
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ fun HomeScreen() {
|
|||||||
onPauseOrDispose { }
|
onPauseOrDispose { }
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(authState.signerRequired) {
|
||||||
chatViewModel.refreshChatRooms()
|
chatViewModel.refreshChatRooms()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
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
|
||||||
@@ -44,10 +43,10 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
import coop.composeapp.generated.resources.ic_scanner
|
import coop.composeapp.generated.resources.ic_scanner
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.Keys
|
import rust.nostr.sdk.Keys
|
||||||
import rust.nostr.sdk.NostrConnectUri
|
import rust.nostr.sdk.NostrConnectUri
|
||||||
@@ -65,12 +64,12 @@ fun ImportScreen() {
|
|||||||
val qrScanResult = LocalScanResult.current
|
val qrScanResult = LocalScanResult.current
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val authViewModel = LocalAuthViewModel.current
|
val authViewModel = LocalAuthViewModel.current
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
val authState by authViewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
var secret by remember { mutableStateOf("") }
|
var secret by remember { mutableStateOf("") }
|
||||||
var password by remember { mutableStateOf("") }
|
var password by remember { mutableStateOf("") }
|
||||||
var requirePassword by remember { mutableStateOf(false) }
|
var requirePassword by remember { mutableStateOf(false) }
|
||||||
var loading by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
LaunchedEffect(qrScanResult.content) {
|
LaunchedEffect(qrScanResult.content) {
|
||||||
qrScanResult.content?.let { result ->
|
qrScanResult.content?.let { result ->
|
||||||
@@ -98,6 +97,20 @@ fun ImportScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Navigate to Home on successful import (signerRequired becomes false)
|
||||||
|
LaunchedEffect(authState.signerRequired) {
|
||||||
|
if (authState.signerRequired == false) {
|
||||||
|
navigator.navigate(Screen.Home)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show import errors via snackbar
|
||||||
|
LaunchedEffect(authState.importError) {
|
||||||
|
authState.importError?.let {
|
||||||
|
snackbarHostState.showSnackbar(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
@@ -164,7 +177,7 @@ fun ImportScreen() {
|
|||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = secret,
|
value = secret,
|
||||||
onValueChange = { secret = it },
|
onValueChange = { secret = it },
|
||||||
enabled = !loading,
|
enabled = !authState.isImporting,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions = KeyboardOptions(
|
||||||
@@ -209,7 +222,7 @@ fun ImportScreen() {
|
|||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = password,
|
value = password,
|
||||||
onValueChange = { password = it },
|
onValueChange = { password = it },
|
||||||
enabled = !loading && requirePassword,
|
enabled = !authState.isImporting && requirePassword,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions = KeyboardOptions(
|
||||||
@@ -237,25 +250,14 @@ fun ImportScreen() {
|
|||||||
Spacer(modifier = Modifier.size(16.dp))
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
|
||||||
loading = true
|
|
||||||
try {
|
|
||||||
// Import the identity
|
|
||||||
authViewModel.importIdentity(secret, password)
|
authViewModel.importIdentity(secret, password)
|
||||||
// Navigate to the home screen
|
|
||||||
navigator.navigate(Screen.Home)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
snackbarHostState.showSnackbar(e.message ?: "Error")
|
|
||||||
loading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(ButtonDefaults.MediumContainerHeight),
|
.height(ButtonDefaults.MediumContainerHeight),
|
||||||
enabled = secret.isNotBlank() && !loading,
|
enabled = secret.isNotBlank() && !authState.isImporting,
|
||||||
) {
|
) {
|
||||||
if (loading) {
|
if (authState.isImporting) {
|
||||||
LoadingIndicator()
|
LoadingIndicator()
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
@@ -44,6 +46,7 @@ import androidx.core.net.toUri
|
|||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.coop
|
import coop.composeapp.generated.resources.coop
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import su.reya.coop.LocalAuthViewModel
|
import su.reya.coop.LocalAuthViewModel
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
@@ -58,8 +61,24 @@ fun OnboardingScreen() {
|
|||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val authViewModel = LocalAuthViewModel.current
|
val authViewModel = LocalAuthViewModel.current
|
||||||
|
|
||||||
|
val authState by authViewModel.state.collectAsStateWithLifecycle()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// Navigate to Home on successful external signer connection
|
||||||
|
LaunchedEffect(authState.signerRequired) {
|
||||||
|
if (authState.signerRequired == false) {
|
||||||
|
navigator.navigate(Screen.Home)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show connection errors
|
||||||
|
LaunchedEffect(authState.importError) {
|
||||||
|
authState.importError?.let {
|
||||||
|
snackbarHostState.showSnackbar(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val logoPainter = painterResource(Res.drawable.coop)
|
val logoPainter = painterResource(Res.drawable.coop)
|
||||||
val expressiveFont = getExpressiveFontFamily()
|
val expressiveFont = getExpressiveFontFamily()
|
||||||
|
|
||||||
@@ -157,18 +176,12 @@ fun OnboardingScreen() {
|
|||||||
Spacer(modifier = Modifier.size(8.dp))
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
|
||||||
if (authViewModel.isExternalSignerAvailable()) {
|
if (authViewModel.isExternalSignerAvailable()) {
|
||||||
try {
|
|
||||||
// Connect to the external signer
|
// Connect to the external signer
|
||||||
// TODO: show all available signers?
|
// TODO: show all available signers?
|
||||||
authViewModel.connectExternalSigner()
|
authViewModel.connectExternalSigner()
|
||||||
// Navigate to the home screen
|
|
||||||
navigator.navigate(Screen.Home)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.message?.let { snackbarHostState.showSnackbar(it) }
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
|
scope.launch {
|
||||||
val result = snackbarHostState.showSnackbar(
|
val result = snackbarHostState.showSnackbar(
|
||||||
message = "External signer not installed. Please install Amber or alternatives.",
|
message = "External signer not installed. Please install Amber or alternatives.",
|
||||||
actionLabel = "Install",
|
actionLabel = "Install",
|
||||||
|
|||||||
@@ -120,8 +120,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
|||||||
|
|
||||||
val sendFile = { uri: Uri ->
|
val sendFile = { uri: Uri ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
// Read file on IO dispatcher
|
||||||
// Read file
|
|
||||||
val file = withContext(Dispatchers.IO) {
|
val file = withContext(Dispatchers.IO) {
|
||||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||||
}
|
}
|
||||||
@@ -129,11 +128,8 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
|||||||
// Parse the file content type
|
// Parse the file content type
|
||||||
val type = context.contentResolver.getType(uri)
|
val type = context.contentResolver.getType(uri)
|
||||||
|
|
||||||
// Send message
|
// Send message (handles errors internally via ViewModel)
|
||||||
chatViewModel.sendFileMessage(id, file, type)
|
chatViewModel.sendFileMessage(id, file, type)
|
||||||
} catch (e: Exception) {
|
|
||||||
snackbarHostState.showSnackbar("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ fun ProfileEditor(
|
|||||||
initialBio: String = "",
|
initialBio: String = "",
|
||||||
initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL)
|
initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL)
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onConfirm: suspend (name: String, bio: String, pictureBytes: ByteArray?, contentType: String?) -> Unit
|
onConfirm: (name: String, bio: String, pictureBytes: ByteArray?, contentType: String?) -> Unit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
|||||||
@@ -41,8 +41,7 @@ data class Room(
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun new(rumor: UnsignedEvent, userPubkey: PublicKey): Room {
|
fun new(rumor: UnsignedEvent, userPubkey: PublicKey, id: Long = rumor.roomId()): Room {
|
||||||
val id = rumor.roomId()
|
|
||||||
val createdAt = rumor.createdAt()
|
val createdAt = rumor.createdAt()
|
||||||
val subject = rumor.tags().toVec().find { it.kind() == "subject" }?.content()
|
val subject = rumor.tags().toVec().find { it.kind() == "subject" }?.content()
|
||||||
|
|
||||||
|
|||||||
@@ -170,32 +170,29 @@ class MessageManager(private val nostr: Nostr) {
|
|||||||
|
|
||||||
// Get all DM events
|
// Get all DM events
|
||||||
val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm"))
|
val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm"))
|
||||||
val events = client?.database()?.query(filter)
|
val events = client?.database()?.query(filter)?.toVec() ?: return null
|
||||||
|
|
||||||
// Collect rooms
|
// Collect rooms
|
||||||
val roomsMap: MutableMap<Long, Room> = mutableMapOf()
|
val roomsMap: MutableMap<Long, Room> = mutableMapOf()
|
||||||
|
|
||||||
events
|
events
|
||||||
?.toVec()
|
.map { UnsignedEvent.fromJson(it.content()) }
|
||||||
?.map { UnsignedEvent.fromJson(it.content()) }
|
.filter { it.tags().publicKeys().isNotEmpty() }
|
||||||
?.filter { it.tags().publicKeys().isNotEmpty() }
|
.forEach { rumor ->
|
||||||
?.forEach { event ->
|
val id = rumor.roomId()
|
||||||
val newRoom = Room.new(rumor = event, userPubkey = userPubkey)
|
val isFromMe = rumor.author() == userPubkey
|
||||||
val existingRoom = roomsMap[newRoom.id]
|
val existing = roomsMap[id]
|
||||||
|
val createdAt = rumor.createdAt()
|
||||||
|
|
||||||
// Check if the room already exists
|
// If the room is new or the current rumor is newer than the existing one
|
||||||
if (existingRoom == null || newRoom.createdAt.asSecs() > existingRoom.createdAt.asSecs()) {
|
if (existing == null || createdAt.asSecs() > existing.createdAt.asSecs()) {
|
||||||
val rTag = SingleLetterTag.lowercase(Alphabet.R)
|
// A room is "Ongoing" if it was already marked as such or if the current rumor is from the user
|
||||||
val filter = Filter().kind(kind).pubkey(userPubkey)
|
val isOngoing = (existing?.kind == RoomKind.Ongoing) || isFromMe
|
||||||
.customTag(rTag, newRoom.id.toString())
|
val room = Room.new(rumor = rumor, userPubkey = userPubkey, id = id)
|
||||||
|
roomsMap[id] = if (isOngoing) room.copy(kind = RoomKind.Ongoing) else room
|
||||||
// Determine if it's an ongoing room
|
} else if (isFromMe && existing.kind != RoomKind.Ongoing) {
|
||||||
val isOngoing =
|
// If it's an older rumor but sent by the user, mark the room as Ongoing
|
||||||
client?.database()?.query(filter)?.toVec()?.isNotEmpty() ?: false
|
roomsMap[id] = existing.copy(kind = RoomKind.Ongoing)
|
||||||
|
|
||||||
// Append room to map
|
|
||||||
roomsMap[newRoom.id] =
|
|
||||||
if (isOngoing) newRoom.copy(kind = RoomKind.Ongoing) else newRoom
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,17 +29,15 @@ class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner {
|
|||||||
/**
|
/**
|
||||||
* Switch to a new signer.
|
* Switch to a new signer.
|
||||||
*/
|
*/
|
||||||
suspend fun switch(newSigner: AsyncNostrSigner) = mutex.withLock {
|
suspend fun switch(newSigner: AsyncNostrSigner) {
|
||||||
val pubkey = try {
|
val pubkey = withTimeoutOrNull(20.seconds) { newSigner.getPublicKeyAsync() }
|
||||||
withTimeoutOrNull(20.seconds) {
|
?: throw IllegalStateException("Failed to get public key from signer")
|
||||||
newSigner.getPublicKeyAsync()
|
|
||||||
}
|
mutex.withLock {
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to get public key from signer", e)
|
|
||||||
}
|
|
||||||
signer = newSigner
|
signer = newSigner
|
||||||
_publicKeyFlow.value = pubkey
|
_publicKeyFlow.value = pubkey
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getPublicKeyAsync(): PublicKey? {
|
override suspend fun getPublicKeyAsync(): PublicKey? {
|
||||||
return get().getPublicKeyAsync()
|
return get().getPublicKeyAsync()
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
package su.reya.coop.repository
|
|
||||||
|
|
||||||
import kotlinx.coroutines.channels.Channel
|
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
|
||||||
|
|
||||||
object ErrorRepository {
|
|
||||||
private val _errors = Channel<String>(Channel.BUFFERED)
|
|
||||||
val errors = _errors.receiveAsFlow()
|
|
||||||
|
|
||||||
fun showError(message: String) {
|
|
||||||
_errors.trySend(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -23,15 +23,16 @@ import kotlin.time.Duration.Companion.seconds
|
|||||||
data class AuthState(
|
data class AuthState(
|
||||||
val signerRequired: Boolean? = null,
|
val signerRequired: Boolean? = null,
|
||||||
val isNotificationBannerDismissed: Boolean = false,
|
val isNotificationBannerDismissed: Boolean = false,
|
||||||
|
val isImporting: Boolean = false,
|
||||||
|
val importError: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
class AuthViewModel(
|
class AuthViewModel(
|
||||||
private val nostr: Nostr,
|
private val nostr: Nostr,
|
||||||
private val storage: AppStorage,
|
private val storage: AppStorage,
|
||||||
|
private val mediaRepository: MediaRepository,
|
||||||
private val externalSignerHandler: ExternalSignerHandler? = null,
|
private val externalSignerHandler: ExternalSignerHandler? = null,
|
||||||
) : BaseViewModel() {
|
) : BaseViewModel() {
|
||||||
private val mediaRepository = MediaRepository()
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val KEY_USER_SIGNER = "user_signer"
|
private const val KEY_USER_SIGNER = "user_signer"
|
||||||
private const val KEY_APP_KEYS = "app_keys"
|
private const val KEY_APP_KEYS = "app_keys"
|
||||||
@@ -161,18 +162,30 @@ class AuthViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun importIdentity(secret: String, password: String? = null) {
|
fun importIdentity(secret: String, password: String? = null) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isImporting = true, importError = null) }
|
||||||
|
try {
|
||||||
val (signer, decryptedSecret) = createSigner(secret, password)
|
val (signer, decryptedSecret) = createSigner(secret, password)
|
||||||
// Update signer
|
// Update signer
|
||||||
nostr.setSigner(signer)
|
nostr.setSigner(signer)
|
||||||
// Persist the secret in the secret storage
|
// Persist the secret in the secret storage
|
||||||
storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret)
|
storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret)
|
||||||
// Update local states
|
// Update local states
|
||||||
_state.update { it.copy(signerRequired = false) }
|
_state.update { it.copy(signerRequired = false, isImporting = false) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Import failed: ${e.message}")
|
||||||
|
_state.update { it.copy(isImporting = false, importError = e.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun connectExternalSigner() {
|
fun connectExternalSigner() {
|
||||||
val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available")
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isImporting = true, importError = null) }
|
||||||
|
try {
|
||||||
|
val handler = externalSignerHandler
|
||||||
|
?: throw IllegalStateException("Signer not available")
|
||||||
|
|
||||||
val permissions = SignerPermissions.toJson(
|
val permissions = SignerPermissions.toJson(
|
||||||
listOf(
|
listOf(
|
||||||
@@ -195,31 +208,52 @@ class AuthViewModel(
|
|||||||
// Update signer
|
// Update signer
|
||||||
nostr.setSigner(signer)
|
nostr.setSigner(signer)
|
||||||
// Store the signer in the secret storage
|
// Store the signer in the secret storage
|
||||||
storage.setSecret(KEY_USER_SIGNER, "nip55://${result.packageName}/${result.pubkey.toHex()}")
|
storage.setSecret(
|
||||||
|
KEY_USER_SIGNER,
|
||||||
|
"nip55://${result.packageName}/${result.pubkey.toHex()}"
|
||||||
|
)
|
||||||
// Update local states
|
// Update local states
|
||||||
_state.update { it.copy(signerRequired = false) }
|
_state.update { it.copy(signerRequired = false, isImporting = false) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("External signer connection failed: ${e.message}")
|
||||||
|
_state.update { it.copy(isImporting = false, importError = e.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isExternalSignerAvailable(): Boolean {
|
fun isExternalSignerAvailable(): Boolean {
|
||||||
return externalSignerHandler?.isAvailable() == true
|
return externalSignerHandler?.isAvailable() == true
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun createIdentity(
|
fun createIdentity(
|
||||||
name: String,
|
name: String,
|
||||||
bio: String?,
|
bio: String?,
|
||||||
picture: ByteArray?,
|
picture: ByteArray?,
|
||||||
contentType: String? = null
|
contentType: String? = null
|
||||||
) {
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isImporting = true, importError = null) }
|
||||||
|
try {
|
||||||
val keys = Keys.generate()
|
val keys = Keys.generate()
|
||||||
val secret = keys.secretKey().toBech32()
|
val secret = keys.secretKey().toBech32()
|
||||||
val avatarUrl = picture?.let {
|
val avatarUrl = picture?.let {
|
||||||
mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg")
|
mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg")
|
||||||
}
|
}
|
||||||
// Create identity
|
// Create identity
|
||||||
nostr.profiles.createIdentity(keys = keys, name = name, bio = bio, picture = avatarUrl)
|
nostr.profiles.createIdentity(
|
||||||
|
keys = keys,
|
||||||
|
name = name,
|
||||||
|
bio = bio,
|
||||||
|
picture = avatarUrl
|
||||||
|
)
|
||||||
// Persist the secret in the secret storage
|
// Persist the secret in the secret storage
|
||||||
storage.setSecret(KEY_USER_SIGNER, secret)
|
storage.setSecret(KEY_USER_SIGNER, secret)
|
||||||
// Update local states
|
// Update local states
|
||||||
_state.update { it.copy(signerRequired = false) }
|
_state.update { it.copy(signerRequired = false, isImporting = false) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Identity creation failed: ${e.message}")
|
||||||
|
_state.update { it.copy(isImporting = false, importError = e.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
package su.reya.coop.viewmodel
|
package su.reya.coop.viewmodel
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import su.reya.coop.repository.ErrorRepository
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
|
||||||
abstract class BaseViewModel : ViewModel() {
|
abstract class BaseViewModel : ViewModel() {
|
||||||
|
private val _errorEvents = MutableSharedFlow<String>(extraBufferCapacity = 10)
|
||||||
|
val errorEvents = _errorEvents.asSharedFlow()
|
||||||
|
|
||||||
protected fun showError(message: String) {
|
protected fun showError(message: String) {
|
||||||
ErrorRepository.showError(message)
|
_errorEvents.tryEmit(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package su.reya.coop.viewmodel
|
package su.reya.coop.viewmodel
|
||||||
|
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -24,14 +25,15 @@ import su.reya.coop.repository.MediaRepository
|
|||||||
import su.reya.coop.roomId
|
import su.reya.coop.roomId
|
||||||
|
|
||||||
data class ChatState(
|
data class ChatState(
|
||||||
val rooms: Set<Room> = emptySet(),
|
val rooms: Map<Long, Room> = emptyMap(),
|
||||||
val isSyncing: Boolean = false,
|
val isSyncing: Boolean = false,
|
||||||
val isPartialProcessedGiftWrap: Boolean = false,
|
val isPartialProcessedGiftWrap: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
|
class ChatViewModel(
|
||||||
private val mediaRepository = MediaRepository()
|
private val nostr: Nostr,
|
||||||
|
private val mediaRepository: MediaRepository,
|
||||||
|
) : BaseViewModel() {
|
||||||
private val _state = MutableStateFlow(ChatState())
|
private val _state = MutableStateFlow(ChatState())
|
||||||
val state = combine(
|
val state = combine(
|
||||||
_state,
|
_state,
|
||||||
@@ -48,8 +50,8 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
|
|||||||
private val _sentReports = MutableSharedFlow<Map<EventId, List<RelayUrl>>>()
|
private val _sentReports = MutableSharedFlow<Map<EventId, List<RelayUrl>>>()
|
||||||
val sentReport = _sentReports.asSharedFlow()
|
val sentReport = _sentReports.asSharedFlow()
|
||||||
|
|
||||||
val chatRooms = state.map { it.rooms }
|
val chatRooms = state.map { it.rooms.values.sortedByDescending { it.createdAt.asSecs() } }
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet())
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||||
|
|
||||||
val isSyncing = state.map { it.isSyncing }
|
val isSyncing = state.map { it.isSyncing }
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||||
@@ -69,8 +71,8 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
|
|||||||
_state.update { it.copy(isPartialProcessedGiftWrap = true) }
|
_state.update { it.copy(isPartialProcessedGiftWrap = true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh UI every 10 messages OR when sync is fully done
|
// Refresh UI every 100 messages OR when sync is fully done
|
||||||
if (syncState.processedCount % 10 == 0 || !syncState.isSyncing) {
|
if (syncState.processedCount % 100 == 0 || !syncState.isSyncing) {
|
||||||
refreshChatRooms()
|
refreshChatRooms()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,16 +82,12 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
|
|||||||
launch {
|
launch {
|
||||||
nostr.newEvents.collect { event ->
|
nostr.newEvents.collect { event ->
|
||||||
val roomId = event.roomId()
|
val roomId = event.roomId()
|
||||||
val existingRoom = _state.value.rooms.firstOrNull { it.id == roomId }
|
val existingRoom = _state.value.rooms[roomId]
|
||||||
|
|
||||||
if (existingRoom == null) {
|
if (existingRoom == null) {
|
||||||
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@collect
|
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@collect
|
||||||
val newRoom = Room.new(event, currentUser)
|
val newRoom = Room.new(event, currentUser)
|
||||||
_state.update {
|
_state.update { it.copy(rooms = it.rooms + (newRoom.id to newRoom)) }
|
||||||
it.copy(
|
|
||||||
rooms = (it.rooms + newRoom).sortedDescending().toSet()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
updateRoomList(roomId, event)
|
updateRoomList(roomId, event)
|
||||||
}
|
}
|
||||||
@@ -97,9 +95,6 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
|
|||||||
_newEvents.emit(event)
|
_newEvents.emit(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial load of rooms
|
|
||||||
refreshChatRooms()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +115,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
|
|||||||
|
|
||||||
// Check if the room already exists
|
// Check if the room already exists
|
||||||
val id = rumor.roomId()
|
val id = rumor.roomId()
|
||||||
val existingRoom = _state.value.rooms.firstOrNull { it.id == id }
|
val existingRoom = _state.value.rooms[id]
|
||||||
|
|
||||||
// If the room already exists, return its ID
|
// If the room already exists, return its ID
|
||||||
if (existingRoom != null) {
|
if (existingRoom != null) {
|
||||||
@@ -131,7 +126,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
|
|||||||
val room = Room.new(rumor, currentUser)
|
val room = Room.new(rumor, currentUser)
|
||||||
|
|
||||||
// Update the chat rooms state
|
// Update the chat rooms state
|
||||||
_state.update { it.copy(rooms = (it.rooms + room).sortedDescending().toSet()) }
|
_state.update { it.copy(rooms = it.rooms + (room.id to room)) }
|
||||||
|
|
||||||
return room.id
|
return room.id
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -140,21 +135,19 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getChatRoom(id: Long): Room? {
|
fun getChatRoom(id: Long): Room? {
|
||||||
return _state.value.rooms.firstOrNull { it.id == id }
|
return _state.value.rooms[id]
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun refreshChatRooms() {
|
suspend fun refreshChatRooms() {
|
||||||
try {
|
try {
|
||||||
val rooms = nostr.messages.getChatRooms() ?: emptySet()
|
val rooms = nostr.messages.getChatRooms() ?: emptySet()
|
||||||
_state.update { currentState ->
|
_state.update { currentState ->
|
||||||
val merged = currentState.rooms.associateBy { it.id }.toMutableMap()
|
val newMap = currentState.rooms.toMutableMap()
|
||||||
// Add or update rooms from the database
|
rooms.forEach { room -> newMap[room.id] = room }
|
||||||
rooms.forEach { room ->
|
currentState.copy(rooms = newMap)
|
||||||
merged[room.id] = room
|
|
||||||
}
|
|
||||||
// Return as a sorted set to maintain UI consistency
|
|
||||||
currentState.copy(rooms = merged.values.sortedDescending().toSet())
|
|
||||||
}
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -186,6 +179,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
|
|||||||
fun sendMessage(roomId: Long, message: String, replies: List<EventId> = emptyList()) {
|
fun sendMessage(roomId: Long, message: String, replies: List<EventId> = emptyList()) {
|
||||||
if (message.isEmpty()) {
|
if (message.isEmpty()) {
|
||||||
showError("Message cannot be empty")
|
showError("Message cannot be empty")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
@@ -206,7 +200,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun sendFileMessage(
|
fun sendFileMessage(
|
||||||
roomId: Long,
|
roomId: Long,
|
||||||
file: ByteArray?,
|
file: ByteArray?,
|
||||||
contentType: String? = "image/jpeg",
|
contentType: String? = "image/jpeg",
|
||||||
@@ -214,11 +208,13 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
|
|||||||
) {
|
) {
|
||||||
if (file == null) return
|
if (file == null) return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val uri = mediaRepository.blossomUpload(nostr.signer.get(), file, contentType)
|
val uri = mediaRepository.blossomUpload(nostr.signer.get(), file, contentType)
|
||||||
if (uri != null) sendMessage(roomId, uri, replies)
|
if (uri != null) sendMessage(roomId, uri, replies)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw IllegalArgumentException("Error: ${e.message}")
|
showError("File upload failed: ${e.message}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,24 +231,19 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
|
|||||||
|
|
||||||
private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) {
|
private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) {
|
||||||
_state.update { currentState ->
|
_state.update { currentState ->
|
||||||
val updatedRooms = currentState.rooms.map { room ->
|
val room = currentState.rooms[roomId] ?: return@update currentState
|
||||||
if (room.id == roomId) {
|
val updatedRoom = room.copy(
|
||||||
room.copy(
|
|
||||||
lastMessage = newMessage.content(),
|
lastMessage = newMessage.content(),
|
||||||
createdAt = newMessage.createdAt()
|
createdAt = newMessage.createdAt()
|
||||||
)
|
)
|
||||||
} else {
|
currentState.copy(rooms = currentState.rooms + (roomId to updatedRoom))
|
||||||
room
|
|
||||||
}
|
|
||||||
}.sortedDescending().toSet()
|
|
||||||
currentState.copy(rooms = updatedRooms)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun resetInternalState() {
|
fun resetInternalState() {
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
rooms = emptySet(),
|
rooms = emptyMap(),
|
||||||
isPartialProcessedGiftWrap = false,
|
isPartialProcessedGiftWrap = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package su.reya.coop.viewmodel
|
|||||||
|
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.awaitCancellation
|
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -10,11 +9,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flow
|
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
@@ -39,30 +36,12 @@ data class NostrAppState(
|
|||||||
val isRelayListEmpty: Boolean = false,
|
val isRelayListEmpty: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
|
class NostrViewModel(
|
||||||
private val mediaRepository = MediaRepository()
|
private val nostr: Nostr,
|
||||||
|
private val mediaRepository: MediaRepository,
|
||||||
private val alwaysRunTasks = flow {
|
) : BaseViewModel() {
|
||||||
coroutineScope {
|
|
||||||
val observerJob = launch { runObserver() }
|
|
||||||
val batchingJob = launch { runMetadataBatching() }
|
|
||||||
try {
|
|
||||||
emit(Unit)
|
|
||||||
awaitCancellation()
|
|
||||||
} finally {
|
|
||||||
observerJob.cancel()
|
|
||||||
batchingJob.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val _appState = MutableStateFlow(NostrAppState())
|
private val _appState = MutableStateFlow(NostrAppState())
|
||||||
val appState: StateFlow<NostrAppState> =
|
val appState: StateFlow<NostrAppState> = _appState.asStateFlow()
|
||||||
combine(_appState, alwaysRunTasks) { state, _ -> state }.stateIn(
|
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.WhileSubscribed(5000),
|
|
||||||
initialValue = NostrAppState()
|
|
||||||
)
|
|
||||||
|
|
||||||
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
|
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
|
||||||
val contactList = _contactList.asStateFlow()
|
val contactList = _contactList.asStateFlow()
|
||||||
@@ -83,6 +62,10 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
|
|||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
// Launch continuous background observers
|
||||||
|
viewModelScope.launch { runObserver() }
|
||||||
|
viewModelScope.launch { runMetadataBatching() }
|
||||||
|
|
||||||
// Automatically reconnect bootstrap relays
|
// Automatically reconnect bootstrap relays
|
||||||
reconnect()
|
reconnect()
|
||||||
|
|
||||||
@@ -116,7 +99,7 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun runMetadataBatching() = coroutineScope {
|
private suspend fun runMetadataBatching() {
|
||||||
// Wait until the client is ready
|
// Wait until the client is ready
|
||||||
nostr.waitUntilInitialized()
|
nostr.waitUntilInitialized()
|
||||||
|
|
||||||
@@ -158,15 +141,17 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
// Wait until the client is ready
|
// Wait until the client is ready
|
||||||
nostr.waitUntilInitialized()
|
nostr.waitUntilInitialized()
|
||||||
|
val cache = nostr.profiles.getAllCacheMetadata()
|
||||||
|
|
||||||
nostr.profiles.getAllCacheMetadata().forEach { (pubkey, metadata) ->
|
profilesMutex.withLock {
|
||||||
// Update the metadata state
|
cache.forEach { (pubkey, metadata) ->
|
||||||
updateMetadata(pubkey, Profile(pubkey, metadata))
|
val profile = Profile(pubkey, metadata)
|
||||||
// Update seenPublicKeys to avoid duplicate requests
|
profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile
|
||||||
seenPublicKeys.add(pubkey)
|
seenPublicKeys.add(pubkey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun observeSignerAndCheckRelays() {
|
private fun observeSignerAndCheckRelays() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -190,19 +175,15 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
|
|||||||
|
|
||||||
private fun requestMetadata(pubkey: PublicKey) {
|
private fun requestMetadata(pubkey: PublicKey) {
|
||||||
if (seenPublicKeys.add(pubkey)) {
|
if (seenPublicKeys.add(pubkey)) {
|
||||||
viewModelScope.launch {
|
metadataRequestChannel.trySend(pubkey)
|
||||||
metadataRequestChannel.send(pubkey)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateMetadata(pubkey: PublicKey, profile: Profile) {
|
private suspend fun updateMetadata(pubkey: PublicKey, profile: Profile) {
|
||||||
viewModelScope.launch {
|
|
||||||
profilesMutex.withLock {
|
profilesMutex.withLock {
|
||||||
profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile
|
profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fun getMetadata(pubkey: PublicKey): StateFlow<Profile?> {
|
fun getMetadata(pubkey: PublicKey): StateFlow<Profile?> {
|
||||||
val flow = profiles.getOrPut(pubkey) { MutableStateFlow(null) }
|
val flow = profiles.getOrPut(pubkey) { MutableStateFlow(null) }
|
||||||
@@ -224,12 +205,13 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
|
|||||||
_appState.update { it.copy(isRelayListEmpty = false) }
|
_appState.update { it.copy(isRelayListEmpty = false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateProfile(
|
fun updateProfile(
|
||||||
name: String? = null,
|
name: String? = null,
|
||||||
bio: String? = null,
|
bio: String? = null,
|
||||||
picture: ByteArray? = null,
|
picture: ByteArray? = null,
|
||||||
contentType: String? = null
|
contentType: String? = null
|
||||||
) {
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
_appState.update { it.copy(isBusy = true) }
|
_appState.update { it.copy(isBusy = true) }
|
||||||
try {
|
try {
|
||||||
val avatarUrl =
|
val avatarUrl =
|
||||||
@@ -241,7 +223,8 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl)
|
val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl)
|
||||||
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
val currentUser =
|
||||||
|
nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
||||||
|
|
||||||
// Update the metadata state after successfully published
|
// Update the metadata state after successfully published
|
||||||
updateMetadata(currentUser, Profile(currentUser, newMetadata))
|
updateMetadata(currentUser, Profile(currentUser, newMetadata))
|
||||||
@@ -250,6 +233,8 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
|
|||||||
_appState.update { it.copy(isBusy = false) }
|
_appState.update { it.copy(isBusy = false) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
|
_appState.update { it.copy(isBusy = false) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user