chore: improve performance (#41)
Reviewed-on: #41
This commit was merged in pull request #41.
This commit is contained in:
@@ -34,8 +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.ContactListScreen
|
import su.reya.coop.screens.ContactListScreen
|
||||||
import su.reya.coop.screens.HomeScreen
|
import su.reya.coop.screens.HomeScreen
|
||||||
import su.reya.coop.screens.ImportScreen
|
import su.reya.coop.screens.ImportScreen
|
||||||
@@ -48,9 +47,10 @@ import su.reya.coop.screens.RelayScreen
|
|||||||
import su.reya.coop.screens.RequestListScreen
|
import su.reya.coop.screens.RequestListScreen
|
||||||
import su.reya.coop.screens.ScanScreen
|
import su.reya.coop.screens.ScanScreen
|
||||||
import su.reya.coop.screens.UpdateProfileScreen
|
import su.reya.coop.screens.UpdateProfileScreen
|
||||||
import su.reya.coop.viewmodel.AuthViewModel
|
import su.reya.coop.screens.chat.ChatScreen
|
||||||
import su.reya.coop.viewmodel.ChatViewModel
|
import su.reya.coop.viewmodel.ChatViewModel
|
||||||
import su.reya.coop.viewmodel.NostrViewModel
|
import su.reya.coop.viewmodel.NostrViewModel
|
||||||
|
import su.reya.coop.viewmodel.account.AccountViewModel
|
||||||
|
|
||||||
val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
|
val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
|
||||||
error("No NostrViewModel provided")
|
error("No NostrViewModel provided")
|
||||||
@@ -60,10 +60,11 @@ val LocalChatViewModel = staticCompositionLocalOf<ChatViewModel> {
|
|||||||
error("No ChatViewModel provided")
|
error("No ChatViewModel provided")
|
||||||
}
|
}
|
||||||
|
|
||||||
val LocalAuthViewModel = staticCompositionLocalOf<AuthViewModel> {
|
val LocalAccountViewModel = staticCompositionLocalOf<AccountViewModel> {
|
||||||
error("No AuthViewModel provided")
|
error("No AccountViewModel provided")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
|
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
|
||||||
error("No SnackbarHostState provided")
|
error("No SnackbarHostState provided")
|
||||||
}
|
}
|
||||||
@@ -81,7 +82,7 @@ val LocalScanResult = staticCompositionLocalOf<QrScanResult> {
|
|||||||
fun App(
|
fun App(
|
||||||
nostrViewModel: NostrViewModel,
|
nostrViewModel: NostrViewModel,
|
||||||
chatViewModel: ChatViewModel,
|
chatViewModel: ChatViewModel,
|
||||||
authViewModel: AuthViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val activity = context as? ComponentActivity
|
val activity = context as? ComponentActivity
|
||||||
@@ -90,8 +91,8 @@ fun App(
|
|||||||
val qrScanResult = remember { QrScanResult() }
|
val qrScanResult = remember { QrScanResult() }
|
||||||
|
|
||||||
// Get the signer required state
|
// Get the signer required state
|
||||||
val authState by authViewModel.state.collectAsStateWithLifecycle()
|
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
|
||||||
val signerRequired = authState.signerRequired
|
val signerRequired = accountState.signerRequired
|
||||||
|
|
||||||
// Snackbar
|
// Snackbar
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
@@ -118,10 +119,22 @@ fun App(
|
|||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
ErrorRepository.errors.collect { message ->
|
launch {
|
||||||
|
accountViewModel.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 {
|
||||||
@@ -163,7 +176,7 @@ fun App(
|
|||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalNostrViewModel provides nostrViewModel,
|
LocalNostrViewModel provides nostrViewModel,
|
||||||
LocalChatViewModel provides chatViewModel,
|
LocalChatViewModel provides chatViewModel,
|
||||||
LocalAuthViewModel provides authViewModel,
|
LocalAccountViewModel provides accountViewModel,
|
||||||
LocalSnackbarHostState provides snackbarHostState,
|
LocalSnackbarHostState provides snackbarHostState,
|
||||||
LocalNavigator provides navigator,
|
LocalNavigator provides navigator,
|
||||||
LocalScanResult provides qrScanResult,
|
LocalScanResult provides qrScanResult,
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ import android.content.Intent
|
|||||||
import androidx.activity.result.ActivityResult
|
import androidx.activity.result.ActivityResult
|
||||||
import androidx.activity.result.ActivityResultLauncher
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
class ExternalSignerLauncher {
|
class ExternalSignerLauncher(
|
||||||
|
private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main,
|
||||||
|
) {
|
||||||
private var launcher: ActivityResultLauncher<Intent>? = null
|
private var launcher: ActivityResultLauncher<Intent>? = null
|
||||||
private var pendingResult: CompletableDeferred<ActivityResult>? = null
|
private var pendingResult: CompletableDeferred<ActivityResult>? = null
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
@@ -19,7 +22,7 @@ class ExternalSignerLauncher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun launch(intent: Intent): ActivityResult = mutex.withLock {
|
suspend fun launch(intent: Intent): ActivityResult = mutex.withLock {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(mainDispatcher) {
|
||||||
val deferred = CompletableDeferred<ActivityResult>()
|
val deferred = CompletableDeferred<ActivityResult>()
|
||||||
pendingResult = deferred
|
pendingResult = deferred
|
||||||
launcher?.launch(intent) ?: throw IllegalStateException("Signer not registered")
|
launcher?.launch(intent) ?: throw IllegalStateException("Signer not registered")
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ 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.viewmodel.AuthViewModel
|
import su.reya.coop.repository.MediaRepository
|
||||||
import su.reya.coop.viewmodel.ChatViewModel
|
import su.reya.coop.viewmodel.ChatViewModel
|
||||||
import su.reya.coop.viewmodel.NostrViewModel
|
import su.reya.coop.viewmodel.NostrViewModel
|
||||||
|
import su.reya.coop.viewmodel.account.AccountViewModel
|
||||||
import kotlin.system.exitProcess
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@@ -26,18 +27,19 @@ 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 mediaRepository = MediaRepository()
|
||||||
private val nostrViewModel = NostrViewModel(NostrManager.instance)
|
private val nostrViewModel = NostrViewModel(NostrManager.instance)
|
||||||
private val chatViewModel = ChatViewModel(NostrManager.instance)
|
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 accountViewModel =
|
||||||
AuthViewModel(NostrManager.instance, storage, androidSigner)
|
AccountViewModel(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 {
|
||||||
modelClass.isAssignableFrom(NostrViewModel::class.java) -> nostrViewModel
|
modelClass.isAssignableFrom(NostrViewModel::class.java) -> nostrViewModel
|
||||||
modelClass.isAssignableFrom(ChatViewModel::class.java) -> chatViewModel
|
modelClass.isAssignableFrom(ChatViewModel::class.java) -> chatViewModel
|
||||||
modelClass.isAssignableFrom(AuthViewModel::class.java) -> authViewModel
|
modelClass.isAssignableFrom(AccountViewModel::class.java) -> accountViewModel
|
||||||
else -> throw IllegalArgumentException("Unknown ViewModel class")
|
else -> throw IllegalArgumentException("Unknown ViewModel class")
|
||||||
} as T
|
} as T
|
||||||
}
|
}
|
||||||
@@ -46,7 +48,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
private val nostrViewModel: NostrViewModel by viewModels { factory }
|
private val nostrViewModel: NostrViewModel by viewModels { factory }
|
||||||
private val chatViewModel: ChatViewModel by viewModels { factory }
|
private val chatViewModel: ChatViewModel by viewModels { factory }
|
||||||
private val authViewModel: AuthViewModel by viewModels { factory }
|
private val accountViewModel: AccountViewModel by viewModels { factory }
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||||
@@ -85,14 +87,14 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
// Keep the splash screen visible until the signer check is complete
|
// Keep the splash screen visible until the signer check is complete
|
||||||
splashScreen.setKeepOnScreenCondition {
|
splashScreen.setKeepOnScreenCondition {
|
||||||
authViewModel.state.value.signerRequired == null
|
accountViewModel.state.value.signerRequired == null
|
||||||
}
|
}
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
App(
|
App(
|
||||||
nostrViewModel = nostrViewModel,
|
nostrViewModel = nostrViewModel,
|
||||||
chatViewModel = chatViewModel,
|
chatViewModel = chatViewModel,
|
||||||
authViewModel = authViewModel,
|
accountViewModel = accountViewModel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import androidx.core.app.NotificationCompat
|
|||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.ProcessLifecycleOwner
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -27,6 +28,8 @@ private const val GROUP_KEY_MESSAGES = "su.reya.coop.MESSAGES"
|
|||||||
|
|
||||||
class NostrForegroundService : Service() {
|
class NostrForegroundService : Service() {
|
||||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
|
var ioDispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||||
private val nostr by lazy { NostrManager.instance }
|
private val nostr by lazy { NostrManager.instance }
|
||||||
private var notificationJob: Job? = null
|
private var notificationJob: Job? = null
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import androidx.compose.material3.TopAppBarDefaults
|
|||||||
import androidx.compose.material3.rememberTooltipState
|
import androidx.compose.material3.rememberTooltipState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
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
|
||||||
@@ -59,6 +58,7 @@ import kotlinx.coroutines.launch
|
|||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.Nip05Address
|
import rust.nostr.sdk.Nip05Address
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import su.reya.coop.LocalAccountViewModel
|
||||||
import su.reya.coop.LocalChatViewModel
|
import su.reya.coop.LocalChatViewModel
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
@@ -73,9 +73,10 @@ fun ContactListScreen() {
|
|||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val nostrViewModel = LocalNostrViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
|
val accountViewModel = LocalAccountViewModel.current
|
||||||
val chatViewModel = LocalChatViewModel.current
|
val chatViewModel = LocalChatViewModel.current
|
||||||
|
|
||||||
val contactList by nostrViewModel.contactList.collectAsStateWithLifecycle()
|
val contactList by accountViewModel.contactList.collectAsStateWithLifecycle()
|
||||||
var openAddContactDialog by remember { mutableStateOf(false) }
|
var openAddContactDialog by remember { mutableStateOf(false) }
|
||||||
var contactToDelete by remember { mutableStateOf<PublicKey?>(null) }
|
var contactToDelete by remember { mutableStateOf<PublicKey?>(null) }
|
||||||
|
|
||||||
@@ -202,7 +203,7 @@ fun ContactListScreen() {
|
|||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
contactToDelete?.let { nostrViewModel.removeContact(it) }
|
contactToDelete?.let { accountViewModel.removeContact(it) }
|
||||||
contactToDelete = null
|
contactToDelete = null
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
@@ -223,6 +224,7 @@ fun ContactListScreen() {
|
|||||||
fun AddContactDialog(onDismissRequest: () -> Unit) {
|
fun AddContactDialog(onDismissRequest: () -> Unit) {
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val nostrViewModel = LocalNostrViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
|
val accountViewModel = LocalAccountViewModel.current
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
var contact by remember { mutableStateOf("") }
|
var contact by remember { mutableStateOf("") }
|
||||||
var isError by remember { mutableStateOf(false) }
|
var isError by remember { mutableStateOf(false) }
|
||||||
@@ -266,10 +268,8 @@ fun AddContactDialog(onDismissRequest: () -> Unit) {
|
|||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
scope.launch {
|
accountViewModel.addContact(contact)
|
||||||
val success = nostrViewModel.addContact(contact)
|
onDismissRequest()
|
||||||
if (success) onDismissRequest()
|
|
||||||
}
|
|
||||||
}) {
|
}) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(Res.drawable.ic_check),
|
painter = painterResource(Res.drawable.ic_check),
|
||||||
@@ -328,7 +328,7 @@ fun ContactListItem(
|
|||||||
) {
|
) {
|
||||||
val nostrViewModel = LocalNostrViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
|
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
|
||||||
val profile by profileFlow.collectAsState(initial = null)
|
val profile by profileFlow.collectAsStateWithLifecycle(initialValue = null)
|
||||||
|
|
||||||
SegmentedListItem(
|
SegmentedListItem(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ import coop.composeapp.generated.resources.ic_scanner
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import su.reya.coop.LocalAuthViewModel
|
import su.reya.coop.LocalAccountViewModel
|
||||||
import su.reya.coop.LocalChatViewModel
|
import su.reya.coop.LocalChatViewModel
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
@@ -113,22 +113,22 @@ fun HomeScreen() {
|
|||||||
val clipboardManager = LocalClipboard.current
|
val clipboardManager = LocalClipboard.current
|
||||||
val nostrViewModel = LocalNostrViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val chatViewModel = LocalChatViewModel.current
|
val chatViewModel = LocalChatViewModel.current
|
||||||
val authViewModel = LocalAuthViewModel.current
|
val accountViewModel = LocalAccountViewModel.current
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val sheetState = rememberModalBottomSheetState(true)
|
val sheetState = rememberModalBottomSheetState(true)
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val pullToRefreshState = rememberPullToRefreshState()
|
val pullToRefreshState = rememberPullToRefreshState()
|
||||||
|
|
||||||
val userProfile by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
|
val userProfile by accountViewModel.currentUserProfile.collectAsStateWithLifecycle()
|
||||||
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
|
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val isRelayListEmpty by nostrViewModel.isRelayListEmpty.collectAsStateWithLifecycle()
|
val isRelayListEmpty by accountViewModel.isRelayListEmpty.collectAsStateWithLifecycle()
|
||||||
val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle()
|
val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle()
|
||||||
val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
|
val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val authState by authViewModel.state.collectAsStateWithLifecycle()
|
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
|
||||||
val isBannerDismissed = authState.isNotificationBannerDismissed
|
val isBannerDismissed = accountState.isNotificationBannerDismissed
|
||||||
|
|
||||||
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) }
|
||||||
@@ -155,7 +155,7 @@ fun HomeScreen() {
|
|||||||
onPauseOrDispose { }
|
onPauseOrDispose { }
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(accountState.signerRequired) {
|
||||||
chatViewModel.refreshChatRooms()
|
chatViewModel.refreshChatRooms()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,7 +280,7 @@ fun HomeScreen() {
|
|||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = { authViewModel.dismissNotificationBanner() },
|
onClick = { accountViewModel.dismissNotificationBanner() },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
) {
|
) {
|
||||||
Text(text = "Maybe later")
|
Text(text = "Maybe later")
|
||||||
@@ -319,11 +319,9 @@ fun HomeScreen() {
|
|||||||
isRefreshing = isRefreshing,
|
isRefreshing = isRefreshing,
|
||||||
state = pullToRefreshState,
|
state = pullToRefreshState,
|
||||||
onRefresh = {
|
onRefresh = {
|
||||||
scope.launch {
|
|
||||||
isRefreshing = true
|
isRefreshing = true
|
||||||
chatViewModel.refreshChatRooms()
|
chatViewModel.refreshChatRooms()
|
||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
}
|
|
||||||
},
|
},
|
||||||
indicator = {
|
indicator = {
|
||||||
PullToRefreshDefaults.LoadingIndicator(
|
PullToRefreshDefaults.LoadingIndicator(
|
||||||
@@ -469,7 +467,7 @@ fun HomeScreen() {
|
|||||||
// Show the relay setup dialog if the msg relay list is empty
|
// Show the relay setup dialog if the msg relay list is empty
|
||||||
if (isRelayListEmpty) {
|
if (isRelayListEmpty) {
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = { nostrViewModel.dismissRelayWarning() },
|
onDismissRequest = { accountViewModel.dismissRelayWarning() },
|
||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
) {
|
) {
|
||||||
@@ -573,15 +571,9 @@ fun HomeScreen() {
|
|||||||
TextButton(
|
TextButton(
|
||||||
enabled = !isBusy,
|
enabled = !isBusy,
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
|
||||||
isBusy = true
|
isBusy = true
|
||||||
try {
|
accountViewModel.refetchMsgRelays()
|
||||||
nostrViewModel.refetchMsgRelays()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
snackbarHostState.showSnackbar("Failed to refresh metadata: ${e.message}")
|
|
||||||
}
|
|
||||||
isBusy = false
|
isBusy = false
|
||||||
}
|
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
@@ -595,10 +587,8 @@ fun HomeScreen() {
|
|||||||
Button(
|
Button(
|
||||||
enabled = !isBusy,
|
enabled = !isBusy,
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
accountViewModel.useDefaultMsgRelayList()
|
||||||
nostrViewModel.useDefaultMsgRelayList()
|
scope.launch { sheetState.hide() }
|
||||||
sheetState.hide()
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
@@ -746,7 +736,7 @@ fun BottomMenuList(
|
|||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val nostrViewModel = LocalNostrViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val chatViewModel = LocalChatViewModel.current
|
val chatViewModel = LocalChatViewModel.current
|
||||||
val authViewModel = LocalAuthViewModel.current
|
val accountViewModel = LocalAccountViewModel.current
|
||||||
|
|
||||||
val defaultMenuList = listOf(
|
val defaultMenuList = listOf(
|
||||||
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
|
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
|
||||||
@@ -778,8 +768,8 @@ fun BottomMenuList(
|
|||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
onDismiss {
|
onDismiss {
|
||||||
authViewModel.logout(onLogout = {
|
accountViewModel.logout(onLogout = {
|
||||||
nostrViewModel.resetInternalState()
|
accountViewModel.resetInternalState()
|
||||||
chatViewModel.resetInternalState()
|
chatViewModel.resetInternalState()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,14 +43,14 @@ 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
|
||||||
import su.reya.coop.LocalAuthViewModel
|
import su.reya.coop.LocalAccountViewModel
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalScanResult
|
import su.reya.coop.LocalScanResult
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
@@ -64,13 +63,13 @@ fun ImportScreen() {
|
|||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val qrScanResult = LocalScanResult.current
|
val qrScanResult = LocalScanResult.current
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val authViewModel = LocalAuthViewModel.current
|
val accountViewModel = LocalAccountViewModel.current
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
val accountState by accountViewModel.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(accountState.signerRequired) {
|
||||||
|
if (accountState.signerRequired == false) {
|
||||||
|
navigator.navigate(Screen.Home)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show import errors via snackbar
|
||||||
|
LaunchedEffect(accountState.importError) {
|
||||||
|
accountState.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 = !accountState.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 = !accountState.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 {
|
accountViewModel.importIdentity(secret, password)
|
||||||
loading = true
|
|
||||||
try {
|
|
||||||
// Import the identity
|
|
||||||
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() && !accountState.isImporting,
|
||||||
) {
|
) {
|
||||||
if (loading) {
|
if (accountState.isImporting) {
|
||||||
LoadingIndicator()
|
LoadingIndicator()
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -21,16 +21,16 @@ import coop.composeapp.generated.resources.Res
|
|||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
import io.github.alexzhirkevich.qrose.rememberQrCodePainter
|
import io.github.alexzhirkevich.qrose.rememberQrCodePainter
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
import su.reya.coop.LocalAccountViewModel
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MyQrScreen() {
|
fun MyQrScreen() {
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val nostrViewModel = LocalNostrViewModel.current
|
val accountViewModel = LocalAccountViewModel.current
|
||||||
val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
|
val currentUser by accountViewModel.currentUserProfile.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import coop.composeapp.generated.resources.ic_scanner
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import su.reya.coop.LocalAccountViewModel
|
||||||
import su.reya.coop.LocalChatViewModel
|
import su.reya.coop.LocalChatViewModel
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
@@ -72,9 +73,10 @@ fun NewChatScreen() {
|
|||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val qrScanResult = LocalScanResult.current
|
val qrScanResult = LocalScanResult.current
|
||||||
val nostrViewModel = LocalNostrViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
|
val accountViewModel = LocalAccountViewModel.current
|
||||||
val chatViewModel = LocalChatViewModel.current
|
val chatViewModel = LocalChatViewModel.current
|
||||||
|
|
||||||
val contactList by nostrViewModel.contactList.collectAsStateWithLifecycle()
|
val contactList by accountViewModel.contactList.collectAsStateWithLifecycle()
|
||||||
var query by remember { mutableStateOf("") }
|
var query by remember { mutableStateOf("") }
|
||||||
|
|
||||||
val createGroup = remember { mutableStateOf(false) }
|
val createGroup = remember { mutableStateOf(false) }
|
||||||
@@ -96,15 +98,17 @@ fun NewChatScreen() {
|
|||||||
selectedReceivers.add(pubkey)
|
selectedReceivers.add(pubkey)
|
||||||
}
|
}
|
||||||
} else if (query.contains("@")) {
|
} else if (query.contains("@")) {
|
||||||
val pubkey = nostrViewModel.searchByAddress(query)
|
accountViewModel.searchByAddress(query) { pubkey ->
|
||||||
if (pubkey != null) {
|
if (pubkey != null) {
|
||||||
selectedReceivers.add(pubkey)
|
selectedReceivers.add(pubkey)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
val results = nostrViewModel.searchByNostr(query)
|
accountViewModel.searchByNostr(query) { results ->
|
||||||
searchResults.clear()
|
searchResults.clear()
|
||||||
searchResults.addAll(results)
|
searchResults.addAll(results)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
query = ""
|
query = ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,26 @@
|
|||||||
package su.reya.coop.screens
|
package su.reya.coop.screens
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import su.reya.coop.LocalAuthViewModel
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import su.reya.coop.LocalAccountViewModel
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.shared.ProfileEditor
|
import su.reya.coop.shared.ProfileEditor
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NewIdentityScreen() {
|
fun NewIdentityScreen() {
|
||||||
val authViewModel = LocalAuthViewModel.current
|
val accountViewModel = LocalAccountViewModel.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
|
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
ProfileEditor(
|
ProfileEditor(
|
||||||
title = "Create a new identity",
|
title = "Create a new identity",
|
||||||
buttonLabel = "Continue",
|
buttonLabel = "Continue",
|
||||||
|
isBusy = accountState.isImporting,
|
||||||
onBack = { navigator.goBack() },
|
onBack = { navigator.goBack() },
|
||||||
onConfirm = { name, bio, bytes, type ->
|
onConfirm = { name, bio, bytes, type ->
|
||||||
authViewModel.createIdentity(name, bio, bytes, type)
|
accountViewModel.createIdentity(name, bio, bytes, type)
|
||||||
navigator.navigate(Screen.Home)
|
navigator.navigate(Screen.Home)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,8 +46,9 @@ 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.LocalAccountViewModel
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
@@ -57,9 +60,25 @@ fun OnboardingScreen() {
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val authViewModel = LocalAuthViewModel.current
|
val accountViewModel = LocalAccountViewModel.current
|
||||||
|
|
||||||
|
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// Navigate to Home on successful external signer connection
|
||||||
|
LaunchedEffect(accountState.signerRequired) {
|
||||||
|
if (accountState.signerRequired == false) {
|
||||||
|
navigator.navigate(Screen.Home)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show connection errors
|
||||||
|
LaunchedEffect(accountState.importError) {
|
||||||
|
accountState.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 (accountViewModel.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()
|
accountViewModel.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",
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import androidx.compose.ui.text.input.ImeAction
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
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_check
|
import coop.composeapp.generated.resources.ic_check
|
||||||
@@ -67,14 +68,16 @@ import rust.nostr.sdk.RelayMetadata
|
|||||||
import rust.nostr.sdk.RelayUrl
|
import rust.nostr.sdk.RelayUrl
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
|
import su.reya.coop.LocalAccountViewModel
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RelayScreen() {
|
fun RelayScreen() {
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
|
||||||
val nostrViewModel = LocalNostrViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
|
val accountViewModel = LocalAccountViewModel.current
|
||||||
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val msgRelayList = remember { mutableStateListOf<RelayUrl>() }
|
val msgRelayList = remember { mutableStateListOf<RelayUrl>() }
|
||||||
@@ -96,8 +99,25 @@ fun RelayScreen() {
|
|||||||
var relayToDelete by remember { mutableStateOf<String?>(null) }
|
var relayToDelete by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
relayList.putAll(nostrViewModel.currentUserRelayList())
|
accountViewModel.loadCurrentUserRelayList()
|
||||||
msgRelayList.addAll(nostrViewModel.currentUserMsgRelayList())
|
accountViewModel.loadCurrentUserMsgRelayList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val loadedRelayList by accountViewModel.currentUserRelayList.collectAsStateWithLifecycle()
|
||||||
|
val loadedMsgRelayList by accountViewModel.currentUserMsgRelayList.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
LaunchedEffect(loadedRelayList) {
|
||||||
|
if (loadedRelayList.isNotEmpty()) {
|
||||||
|
relayList.clear()
|
||||||
|
relayList.putAll(loadedRelayList)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(loadedMsgRelayList) {
|
||||||
|
if (loadedMsgRelayList.isNotEmpty()) {
|
||||||
|
msgRelayList.clear()
|
||||||
|
msgRelayList.addAll(loadedMsgRelayList)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -314,20 +334,16 @@ fun RelayScreen() {
|
|||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
|
||||||
if (msgRelayList.size == 1) {
|
if (msgRelayList.size == 1) {
|
||||||
|
scope.launch {
|
||||||
snackbarHostState.showSnackbar("You must have at least one relay")
|
snackbarHostState.showSnackbar("You must have at least one relay")
|
||||||
relayToDelete = null
|
|
||||||
return@launch
|
|
||||||
}
|
}
|
||||||
try {
|
relayToDelete = null
|
||||||
nostrViewModel.removeMsgRelay(relayToDelete!!)
|
return@TextButton
|
||||||
|
}
|
||||||
|
accountViewModel.removeMsgRelay(relayToDelete!!)
|
||||||
msgRelayList.removeIf { it.toString() == relayToDelete }
|
msgRelayList.removeIf { it.toString() == relayToDelete }
|
||||||
relayToDelete = null
|
relayToDelete = null
|
||||||
} catch (e: Exception) {
|
|
||||||
snackbarHostState.showSnackbar("Failed to remove relay: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Text("Confirm")
|
Text("Confirm")
|
||||||
@@ -349,7 +365,7 @@ fun AddRelayDialog(
|
|||||||
onMsgRelayAdded: (newRelay: String) -> Unit,
|
onMsgRelayAdded: (newRelay: String) -> Unit,
|
||||||
onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit,
|
onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit,
|
||||||
) {
|
) {
|
||||||
val nostrViewModel = LocalNostrViewModel.current
|
val accountViewModel = LocalAccountViewModel.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -397,27 +413,25 @@ fun AddRelayDialog(
|
|||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
scope.launch {
|
|
||||||
if (!isError) {
|
if (!isError) {
|
||||||
when (selected) {
|
when (selected) {
|
||||||
"Messaging" -> {
|
"Messaging" -> {
|
||||||
nostrViewModel.addMsgRelay(relayAddress)
|
accountViewModel.addMsgRelay(relayAddress)
|
||||||
onMsgRelayAdded(relayAddress)
|
onMsgRelayAdded(relayAddress)
|
||||||
}
|
}
|
||||||
|
|
||||||
"Inbox" -> {
|
"Inbox" -> {
|
||||||
nostrViewModel.addInboxRelay(relayAddress)
|
accountViewModel.addInboxRelay(relayAddress)
|
||||||
onRelayAdded(relayAddress, RelayMetadata.WRITE)
|
onRelayAdded(relayAddress, RelayMetadata.WRITE)
|
||||||
}
|
}
|
||||||
|
|
||||||
"Outbox" -> {
|
"Outbox" -> {
|
||||||
nostrViewModel.addOutboxRelay(relayAddress)
|
accountViewModel.addOutboxRelay(relayAddress)
|
||||||
onRelayAdded(relayAddress, RelayMetadata.READ)
|
onRelayAdded(relayAddress, RelayMetadata.READ)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onDismissRequest()
|
onDismissRequest()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}) {
|
}) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(Res.drawable.ic_check),
|
painter = painterResource(Res.drawable.ic_check),
|
||||||
|
|||||||
@@ -101,11 +101,9 @@ fun RequestListScreen() {
|
|||||||
isRefreshing = isRefreshing,
|
isRefreshing = isRefreshing,
|
||||||
state = pullToRefreshState,
|
state = pullToRefreshState,
|
||||||
onRefresh = {
|
onRefresh = {
|
||||||
scope.launch {
|
|
||||||
isRefreshing = true
|
isRefreshing = true
|
||||||
chatViewModel.refreshChatRooms()
|
chatViewModel.refreshChatRooms()
|
||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
}
|
|
||||||
},
|
},
|
||||||
indicator = {
|
indicator = {
|
||||||
PullToRefreshDefaults.LoadingIndicator(
|
PullToRefreshDefaults.LoadingIndicator(
|
||||||
|
|||||||
@@ -3,17 +3,18 @@ package su.reya.coop.screens
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import su.reya.coop.LocalAccountViewModel
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
|
||||||
import su.reya.coop.shared.ProfileEditor
|
import su.reya.coop.shared.ProfileEditor
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun UpdateProfileScreen() {
|
fun UpdateProfileScreen() {
|
||||||
val nostrViewModel = LocalNostrViewModel.current
|
val accountViewModel = LocalAccountViewModel.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
|
|
||||||
val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
|
val currentUser by accountViewModel.currentUserProfile.collectAsStateWithLifecycle()
|
||||||
val profile = currentUser?.metadata?.asRecord()
|
val profile = currentUser?.metadata?.asRecord()
|
||||||
|
val isUpdatingProfile by accountViewModel.isUpdatingProfile.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
ProfileEditor(
|
ProfileEditor(
|
||||||
title = "Update profile",
|
title = "Update profile",
|
||||||
@@ -21,9 +22,10 @@ fun UpdateProfileScreen() {
|
|||||||
initialName = profile?.displayName ?: profile?.name ?: "",
|
initialName = profile?.displayName ?: profile?.name ?: "",
|
||||||
initialBio = profile?.about ?: "",
|
initialBio = profile?.about ?: "",
|
||||||
initialPicture = profile?.picture,
|
initialPicture = profile?.picture,
|
||||||
|
isBusy = isUpdatingProfile,
|
||||||
onBack = { navigator.goBack() },
|
onBack = { navigator.goBack() },
|
||||||
onConfirm = { name, bio, bytes, type ->
|
onConfirm = { name, bio, bytes, type ->
|
||||||
nostrViewModel.updateProfile(name, bio, bytes, type)
|
accountViewModel.updateProfile(name, bio, bytes, type)
|
||||||
navigator.goBack()
|
navigator.goBack()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,7 +18,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
|
||||||
@@ -29,10 +28,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.ic_cancel
|
import coop.composeapp.generated.resources.ic_cancel
|
||||||
import coop.composeapp.generated.resources.ic_check_circle
|
import coop.composeapp.generated.resources.ic_check_circle
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import rust.nostr.sdk.Timestamp
|
import rust.nostr.sdk.Timestamp
|
||||||
|
import su.reya.coop.LocalAccountViewModel
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
import su.reya.coop.Room
|
import su.reya.coop.Room
|
||||||
import su.reya.coop.humanReadable
|
import su.reya.coop.humanReadable
|
||||||
@@ -46,7 +45,7 @@ fun ScreenerCard(room: Room) {
|
|||||||
val pubkey = room.members.firstOrNull() ?: return
|
val pubkey = room.members.firstOrNull() ?: return
|
||||||
|
|
||||||
val nostrViewModel = LocalNostrViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val scope = rememberCoroutineScope()
|
val accountViewModel = LocalAccountViewModel.current
|
||||||
|
|
||||||
var isContact by remember { mutableStateOf(false) }
|
var isContact by remember { mutableStateOf(false) }
|
||||||
var mutualContacts by remember { mutableStateOf<Set<PublicKey>>(emptySet()) }
|
var mutualContacts by remember { mutableStateOf<Set<PublicKey>>(emptySet()) }
|
||||||
@@ -56,14 +55,12 @@ fun ScreenerCard(room: Room) {
|
|||||||
val profile by profileFlow.collectAsStateWithLifecycle()
|
val profile by profileFlow.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
LaunchedEffect(pubkey) {
|
LaunchedEffect(pubkey) {
|
||||||
scope.launch {
|
|
||||||
// Check contact
|
// Check contact
|
||||||
nostrViewModel.verifyContact(pubkey).let { isContact = it }
|
accountViewModel.verifyContact(pubkey) { isContact = it }
|
||||||
// Get mutual contacts
|
// Get mutual contacts
|
||||||
nostrViewModel.mutualContacts(pubkey).let { mutualContacts = it }
|
accountViewModel.mutualContacts(pubkey) { mutualContacts = it }
|
||||||
// Get the last activity
|
// Get the last activity
|
||||||
nostrViewModel.verifyActivity(pubkey)?.let { lastActivity = it }
|
accountViewModel.verifyActivity(pubkey) { lastActivity = it }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
|
|||||||
@@ -59,11 +59,13 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
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 kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
|
import su.reya.coop.LocalAccountViewModel
|
||||||
import su.reya.coop.LocalChatViewModel
|
import su.reya.coop.LocalChatViewModel
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
@@ -77,7 +79,11 @@ import su.reya.coop.shared.Avatar
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatScreen(id: Long, screening: Boolean = false) {
|
fun ChatScreen(
|
||||||
|
id: Long,
|
||||||
|
screening: Boolean = false,
|
||||||
|
coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
@@ -87,7 +93,8 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
|||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
// Get current user
|
// Get current user
|
||||||
val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
|
val accountViewModel = LocalAccountViewModel.current
|
||||||
|
val currentUser by accountViewModel.currentUserProfile.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// Get chat room by ID
|
// Get chat room by ID
|
||||||
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
|
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
|
||||||
@@ -120,20 +127,16 @@ 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(coroutineDispatcher) {
|
||||||
val file = withContext(Dispatchers.IO) {
|
|
||||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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}")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,12 +156,13 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
|||||||
|
|
||||||
LaunchedEffect(id) {
|
LaunchedEffect(id) {
|
||||||
// Get messages
|
// Get messages
|
||||||
val initialMessages = chatViewModel.getChatRoomMessages(id)
|
chatViewModel.loadChatRoomMessages(id) { initialMessages ->
|
||||||
messages.clear()
|
messages.clear()
|
||||||
messages.addAll(initialMessages)
|
messages.addAll(initialMessages)
|
||||||
|
|
||||||
// Stop loading spinner
|
// Stop loading spinner
|
||||||
loading = false
|
loading = false
|
||||||
|
}
|
||||||
|
|
||||||
// Get msg relays for each member
|
// Get msg relays for each member
|
||||||
chatViewModel.chatRoomConnect(id)
|
chatViewModel.chatRoomConnect(id)
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import coil3.compose.AsyncImage
|
|||||||
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_plus
|
import coop.composeapp.generated.resources.ic_plus
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -68,8 +69,10 @@ fun ProfileEditor(
|
|||||||
initialName: String = "",
|
initialName: String = "",
|
||||||
initialBio: String = "",
|
initialBio: String = "",
|
||||||
initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL)
|
initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL)
|
||||||
|
isBusy: Boolean = false,
|
||||||
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,
|
||||||
|
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
@@ -79,7 +82,6 @@ fun ProfileEditor(
|
|||||||
var name by remember(initialName) { mutableStateOf(initialName) }
|
var name by remember(initialName) { mutableStateOf(initialName) }
|
||||||
var bio by remember(initialBio) { mutableStateOf(initialBio) }
|
var bio by remember(initialBio) { mutableStateOf(initialBio) }
|
||||||
var picture by remember(initialPicture) { mutableStateOf(initialPicture) }
|
var picture by remember(initialPicture) { mutableStateOf(initialPicture) }
|
||||||
var isBusy by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val hasPicture = remember(picture) {
|
val hasPicture = remember(picture) {
|
||||||
when (picture) {
|
when (picture) {
|
||||||
@@ -267,9 +269,8 @@ fun ProfileEditor(
|
|||||||
.size(ButtonDefaults.MediumContainerHeight),
|
.size(ButtonDefaults.MediumContainerHeight),
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
isBusy = true
|
|
||||||
try {
|
try {
|
||||||
val bytes = withContext(Dispatchers.IO) {
|
val bytes = withContext(ioDispatcher) {
|
||||||
(picture as? Uri)?.let {
|
(picture as? Uri)?.let {
|
||||||
context.contentResolver.openInputStream(it)?.readBytes()
|
context.contentResolver.openInputStream(it)?.readBytes()
|
||||||
}
|
}
|
||||||
@@ -280,7 +281,6 @@ fun ProfileEditor(
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
snackbarHostState.showSnackbar(e.message ?: "Error")
|
snackbarHostState.showSnackbar(e.message ?: "Error")
|
||||||
}
|
}
|
||||||
isBusy = false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = name.isNotBlank() && !isBusy
|
enabled = name.isNotBlank() && !isBusy
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package su.reya.coop.nostr
|
package su.reya.coop.nostr
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
@@ -119,7 +120,9 @@ class MessageManager(private val nostr: Nostr) {
|
|||||||
setCachedRumor(event.id(), unsignedEvent)
|
setCachedRumor(event.id(), unsignedEvent)
|
||||||
|
|
||||||
return unsignedEvent
|
return unsignedEvent
|
||||||
} catch (e: Throwable) {
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
println("Failed to unwrap gift ${event.id().toHex()}: ${e.message}")
|
println("Failed to unwrap gift ${event.id().toHex()}: ${e.message}")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -131,7 +134,9 @@ class MessageManager(private val nostr: Nostr) {
|
|||||||
val event = client?.database()?.query(filter)?.first()
|
val event = client?.database()?.query(filter)?.first()
|
||||||
|
|
||||||
return event?.content()?.let { UnsignedEvent.fromJson(it).ensureId() }
|
return event?.content()?.let { UnsignedEvent.fromJson(it).ensureId() }
|
||||||
} catch (e: Throwable) {
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
throw IllegalStateException("Failed to get cached rumor: ${e.message}", e)
|
throw IllegalStateException("Failed to get cached rumor: ${e.message}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,7 +160,9 @@ class MessageManager(private val nostr: Nostr) {
|
|||||||
.finalizeAsync(Keys.generate())
|
.finalizeAsync(Keys.generate())
|
||||||
|
|
||||||
client?.database()?.saveEvent(event)
|
client?.database()?.saveEvent(event)
|
||||||
} catch (e: Throwable) {
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
println("Failed to set cached rumor: ${e.message}")
|
println("Failed to set cached rumor: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,36 +177,35 @@ 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return roomsMap.values.sortedByDescending { it.createdAt.asSecs() }.toSet()
|
return roomsMap.values.sortedByDescending { it.createdAt.asSecs() }.toSet()
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("Failed to get chat rooms: ${e.message}")
|
println("Failed to get chat rooms: ${e.message}")
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package su.reya.coop.nostr
|
package su.reya.coop.nostr
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.supervisorScope
|
import kotlinx.coroutines.supervisorScope
|
||||||
import rust.nostr.sdk.AsyncNostrSigner
|
import rust.nostr.sdk.AsyncNostrSigner
|
||||||
@@ -30,6 +33,7 @@ import rust.nostr.sdk.Timestamp
|
|||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
import rust.nostr.sdk.initLogger
|
import rust.nostr.sdk.initLogger
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
object NostrManager {
|
object NostrManager {
|
||||||
val instance = Nostr()
|
val instance = Nostr()
|
||||||
@@ -47,7 +51,9 @@ object NostrManager {
|
|||||||
val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY
|
val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY
|
||||||
}
|
}
|
||||||
|
|
||||||
class Nostr {
|
class Nostr(
|
||||||
|
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||||
|
) {
|
||||||
var client: Client? = null
|
var client: Client? = null
|
||||||
private set
|
private set
|
||||||
var signer: UniversalSigner = UniversalSigner(Keys.generate())
|
var signer: UniversalSigner = UniversalSigner(Keys.generate())
|
||||||
@@ -59,17 +65,18 @@ class Nostr {
|
|||||||
|
|
||||||
private val isInitialized = MutableStateFlow(false)
|
private val isInitialized = MutableStateFlow(false)
|
||||||
|
|
||||||
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
private val _newEvents = MutableSharedFlow<UnsignedEvent>(
|
||||||
|
replay = 0,
|
||||||
|
extraBufferCapacity = 100,
|
||||||
|
onBufferOverflow = BufferOverflow.SUSPEND
|
||||||
|
)
|
||||||
val newEvents = _newEvents.asSharedFlow()
|
val newEvents = _newEvents.asSharedFlow()
|
||||||
|
|
||||||
suspend fun emitNewEvent(event: UnsignedEvent) {
|
fun emitNewEvent(event: UnsignedEvent) {
|
||||||
_newEvents.emit(event)
|
_newEvents.tryEmit(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun init(
|
suspend fun init(dbPath: String, logLevel: LogLevel = LogLevel.WARN) {
|
||||||
dbPath: String,
|
|
||||||
logLevel: LogLevel = LogLevel.WARN
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
if (isInitialized.value) return
|
if (isInitialized.value) return
|
||||||
|
|
||||||
@@ -105,7 +112,8 @@ class Nostr {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun waitUntilInitialized() {
|
suspend fun waitUntilInitialized() {
|
||||||
isInitialized.first { it }
|
withTimeoutOrNull(30.seconds) { isInitialized.first { it } }
|
||||||
|
?: throw IllegalStateException("Nostr initialization timed out")
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun connectBootstrapRelays() {
|
suspend fun connectBootstrapRelays() {
|
||||||
@@ -159,7 +167,7 @@ class Nostr {
|
|||||||
var processedCount = 0
|
var processedCount = 0
|
||||||
var eoseReceived = false
|
var eoseReceived = false
|
||||||
|
|
||||||
launch(Dispatchers.Default) {
|
launch(defaultDispatcher) {
|
||||||
for (event in giftWrapQueue) {
|
for (event in giftWrapQueue) {
|
||||||
val rumor = messages.extractRumor(event)
|
val rumor = messages.extractRumor(event)
|
||||||
processedCount++
|
processedCount++
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import io.ktor.client.HttpClient
|
|||||||
import io.ktor.client.call.body
|
import io.ktor.client.call.body
|
||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.get
|
||||||
import io.ktor.client.statement.HttpResponse
|
import io.ktor.client.statement.HttpResponse
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import rust.nostr.sdk.AckPolicy
|
import rust.nostr.sdk.AckPolicy
|
||||||
@@ -33,10 +34,19 @@ class ProfileManager(private val nostr: Nostr) {
|
|||||||
private val signer: UniversalSigner get() = nostr.signer
|
private val signer: UniversalSigner get() = nostr.signer
|
||||||
|
|
||||||
private val _metadataUpdates =
|
private val _metadataUpdates =
|
||||||
MutableSharedFlow<Pair<PublicKey, Metadata>>(extraBufferCapacity = 100)
|
MutableSharedFlow<Pair<PublicKey, Metadata>>(
|
||||||
|
replay = 0,
|
||||||
|
extraBufferCapacity = 100,
|
||||||
|
onBufferOverflow = BufferOverflow.SUSPEND
|
||||||
|
)
|
||||||
val metadataUpdates = _metadataUpdates.asSharedFlow()
|
val metadataUpdates = _metadataUpdates.asSharedFlow()
|
||||||
|
|
||||||
private val _contactListUpdates = MutableSharedFlow<List<PublicKey>>(extraBufferCapacity = 100)
|
private val _contactListUpdates =
|
||||||
|
MutableSharedFlow<List<PublicKey>>(
|
||||||
|
replay = 0,
|
||||||
|
extraBufferCapacity = 100,
|
||||||
|
onBufferOverflow = BufferOverflow.SUSPEND
|
||||||
|
)
|
||||||
val contactListUpdates = _contactListUpdates.asSharedFlow()
|
val contactListUpdates = _contactListUpdates.asSharedFlow()
|
||||||
|
|
||||||
suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) {
|
suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) {
|
||||||
@@ -138,19 +148,24 @@ class ProfileManager(private val nostr: Nostr) {
|
|||||||
bio: String? = null,
|
bio: String? = null,
|
||||||
picture: String? = null
|
picture: String? = null
|
||||||
): Metadata {
|
): Metadata {
|
||||||
|
try {
|
||||||
val currentUser =
|
val currentUser =
|
||||||
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
try {
|
// Get the latest metadata event
|
||||||
val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord()
|
val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord()
|
||||||
val newRecord = record.copy(
|
|
||||||
|
// Build a new metadata based on old records
|
||||||
|
val newMetadata = Metadata.fromRecord(
|
||||||
|
record.copy(
|
||||||
displayName = name ?: record.displayName,
|
displayName = name ?: record.displayName,
|
||||||
about = bio ?: record.about,
|
about = bio ?: record.about,
|
||||||
picture = picture ?: record.picture
|
picture = picture ?: record.picture
|
||||||
)
|
)
|
||||||
val newMetadata = Metadata.fromRecord(newRecord)
|
)
|
||||||
val event = EventBuilder.metadata(newMetadata).finalizeAsync(signer)
|
|
||||||
|
|
||||||
|
// Send the new metadata event
|
||||||
|
val event = EventBuilder.metadata(newMetadata).finalizeAsync(signer)
|
||||||
client?.sendEvent(
|
client?.sendEvent(
|
||||||
event = event,
|
event = event,
|
||||||
target = SendEventTarget.broadcast(),
|
target = SendEventTarget.broadcast(),
|
||||||
|
|||||||
@@ -104,6 +104,32 @@ class RelayManager(private val nostr: Nostr) {
|
|||||||
return msgRelayList
|
return msgRelayList
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun getRelayList(publicKey: PublicKey): Map<RelayUrl, RelayMetadata?> {
|
||||||
|
try {
|
||||||
|
val kind = Kind.fromStd(KindStandard.RELAY_LIST)
|
||||||
|
val filter = Filter().kind(kind).author(publicKey).limit(1u)
|
||||||
|
val events = client?.database()?.query(filter)
|
||||||
|
|
||||||
|
return extractRelayList(events?.toVec()?.firstOrNull() ?: return emptyMap())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to get relay list: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setRelaylist(relays: Map<RelayUrl, RelayMetadata?>) {
|
||||||
|
try {
|
||||||
|
val event = EventBuilder.relayList(relays).finalizeAsync(signer)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = event,
|
||||||
|
target = SendEventTarget.broadcast(),
|
||||||
|
ackPolicy = AckPolicy.none(),
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to set msg relays: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun setMsgRelays(urls: List<RelayUrl>) {
|
suspend fun setMsgRelays(urls: List<RelayUrl>) {
|
||||||
try {
|
try {
|
||||||
val event = EventBuilder.nip17RelayList(urls).finalizeAsync(signer)
|
val event = EventBuilder.nip17RelayList(urls).finalizeAsync(signer)
|
||||||
@@ -154,30 +180,4 @@ class RelayManager(private val nostr: Nostr) {
|
|||||||
throw IllegalStateException("Failed to fetch msg relays: ${e.message}", e)
|
throw IllegalStateException("Failed to fetch msg relays: ${e.message}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getRelayList(publicKey: PublicKey): Map<RelayUrl, RelayMetadata?> {
|
|
||||||
try {
|
|
||||||
val kind = Kind.fromStd(KindStandard.RELAY_LIST)
|
|
||||||
val filter = Filter().kind(kind).author(publicKey).limit(1u)
|
|
||||||
val events = client?.database()?.query(filter)
|
|
||||||
|
|
||||||
return extractRelayList(events?.toVec()?.firstOrNull() ?: return emptyMap())
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to get relay list: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun setRelaylist(relays: Map<RelayUrl, RelayMetadata?>) {
|
|
||||||
try {
|
|
||||||
val event = EventBuilder.relayList(relays).finalizeAsync(signer)
|
|
||||||
|
|
||||||
client?.sendEvent(
|
|
||||||
event = event,
|
|
||||||
target = SendEventTarget.broadcast(),
|
|
||||||
ackPolicy = AckPolicy.none(),
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to set msg relays: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,7 @@ package su.reya.coop.repository
|
|||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
import io.ktor.serialization.kotlinx.json.json
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import rust.nostr.sdk.AsyncNostrSigner
|
import rust.nostr.sdk.AsyncNostrSigner
|
||||||
import su.reya.coop.blossom.BlossomClient
|
import su.reya.coop.blossom.BlossomClient
|
||||||
@@ -31,6 +32,8 @@ class MediaRepository {
|
|||||||
signer = signer,
|
signer = signer,
|
||||||
)
|
)
|
||||||
descriptor?.url
|
descriptor?.url
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("Upload failed: ${e.message}")
|
println("Upload failed: ${e.message}")
|
||||||
null
|
null
|
||||||
|
|||||||
@@ -1,225 +0,0 @@
|
|||||||
package su.reya.coop.viewmodel
|
|
||||||
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
|
||||||
import rust.nostr.sdk.AsyncNostrSigner
|
|
||||||
import rust.nostr.sdk.EncryptedSecretKey
|
|
||||||
import rust.nostr.sdk.Keys
|
|
||||||
import rust.nostr.sdk.NostrConnect
|
|
||||||
import rust.nostr.sdk.NostrConnectUri
|
|
||||||
import rust.nostr.sdk.PublicKey
|
|
||||||
import su.reya.coop.AppStorage
|
|
||||||
import su.reya.coop.nostr.ExternalSignerHandler
|
|
||||||
import su.reya.coop.nostr.ExternalSignerProxy
|
|
||||||
import su.reya.coop.nostr.Nostr
|
|
||||||
import su.reya.coop.nostr.SignerPermissions
|
|
||||||
import su.reya.coop.repository.MediaRepository
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
|
|
||||||
data class AuthState(
|
|
||||||
val signerRequired: Boolean? = null,
|
|
||||||
val isNotificationBannerDismissed: Boolean = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
class AuthViewModel(
|
|
||||||
private val nostr: Nostr,
|
|
||||||
private val storage: AppStorage,
|
|
||||||
private val externalSignerHandler: ExternalSignerHandler? = null,
|
|
||||||
) : BaseViewModel() {
|
|
||||||
private val mediaRepository = MediaRepository()
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val KEY_USER_SIGNER = "user_signer"
|
|
||||||
private const val KEY_APP_KEYS = "app_keys"
|
|
||||||
private const val KEY_BANNER_DISMISSED = "notification_banner_dismissed"
|
|
||||||
}
|
|
||||||
|
|
||||||
private val _state = MutableStateFlow(AuthState())
|
|
||||||
val state = _state.asStateFlow()
|
|
||||||
|
|
||||||
init {
|
|
||||||
// Check if the notification banner has been dismissed
|
|
||||||
checkNotificationBannerDismissedStatus()
|
|
||||||
|
|
||||||
// Check local stored secret (secret key or bunker)
|
|
||||||
login()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun checkNotificationBannerDismissedStatus() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val dismissed = storage.get(KEY_BANNER_DISMISSED) == "true"
|
|
||||||
_state.update { it.copy(isNotificationBannerDismissed = dismissed) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun login() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
val secret = withTimeoutOrNull(5.seconds) {
|
|
||||||
storage.getSecret(KEY_USER_SIGNER)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (secret == null) {
|
|
||||||
_state.update { it.copy(signerRequired = true) }
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
runCatching {
|
|
||||||
val (signer, _) = createSigner(secret)
|
|
||||||
nostr.setSigner(signer)
|
|
||||||
}.onSuccess {
|
|
||||||
_state.update { it.copy(signerRequired = false) }
|
|
||||||
}.onFailure { e ->
|
|
||||||
showError("Login failed: ${e.message}")
|
|
||||||
_state.update { it.copy(signerRequired = true) }
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Login failed: ${e.message}")
|
|
||||||
_state.update { it.copy(signerRequired = true) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun logout(onLogout: () -> Unit = {}) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
// Reset the nostr signer and prune the database
|
|
||||||
nostr.signer.switch(Keys.generate())
|
|
||||||
nostr.prune()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Logout encountered an error: ${e.message}")
|
|
||||||
} finally {
|
|
||||||
// Clear credentials from persistent storage
|
|
||||||
storage.clear(KEY_USER_SIGNER)
|
|
||||||
storage.clear(KEY_BANNER_DISMISSED)
|
|
||||||
// Call cleanup callback (e.g. to reset other ViewModels)
|
|
||||||
onLogout()
|
|
||||||
// Reset local states
|
|
||||||
_state.update { it.copy(signerRequired = true) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dismissNotificationBanner() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
storage.set(KEY_BANNER_DISMISSED, "true")
|
|
||||||
_state.update { it.copy(isNotificationBannerDismissed = true) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getOrInitAppKeys(): Keys {
|
|
||||||
val secret = storage.getSecret(KEY_APP_KEYS)
|
|
||||||
// If app keys are already stored, use them
|
|
||||||
if (secret != null) return Keys.parse(secret)
|
|
||||||
// Generate new app keys and save to the secret storage
|
|
||||||
val keys = Keys.generate()
|
|
||||||
storage.setSecret(KEY_APP_KEYS, keys.secretKey().toBech32())
|
|
||||||
return keys
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun createSigner(
|
|
||||||
secret: String,
|
|
||||||
password: String? = null
|
|
||||||
): Pair<AsyncNostrSigner, String?> {
|
|
||||||
return when {
|
|
||||||
secret.startsWith("nsec1") -> Keys.parse(secret) to null
|
|
||||||
|
|
||||||
secret.startsWith("ncryptsec1") -> {
|
|
||||||
if (password == null) throw IllegalArgumentException("Password is required")
|
|
||||||
val enc = EncryptedSecretKey.fromBech32(secret)
|
|
||||||
val secret = enc.decrypt(password)
|
|
||||||
val keys = Keys(secret)
|
|
||||||
|
|
||||||
keys to keys.secretKey().toBech32()
|
|
||||||
}
|
|
||||||
|
|
||||||
secret.startsWith("bunker://") -> {
|
|
||||||
val appKeys = getOrInitAppKeys()
|
|
||||||
val bunker = NostrConnectUri.parse(secret)
|
|
||||||
val timeout = 50.seconds
|
|
||||||
NostrConnect(uri = bunker, appKeys, timeout, null) to null
|
|
||||||
}
|
|
||||||
|
|
||||||
secret.startsWith("nip55://") -> {
|
|
||||||
val handler = externalSignerHandler
|
|
||||||
?: throw IllegalStateException("External signer not available on this platform")
|
|
||||||
|
|
||||||
// Format: nip55://packageName/hexPubkey
|
|
||||||
val parts = secret.removePrefix("nip55://").split("/", limit = 2)
|
|
||||||
val packageName = parts[0]
|
|
||||||
val pubkey = PublicKey.parse(parts[1])
|
|
||||||
|
|
||||||
handler.setPackageName(packageName)
|
|
||||||
ExternalSignerProxy(handler, pubkey) to null
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> throw IllegalArgumentException("Invalid secret format")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun importIdentity(secret: String, password: String? = null) {
|
|
||||||
val (signer, decryptedSecret) = createSigner(secret, password)
|
|
||||||
// Update signer
|
|
||||||
nostr.setSigner(signer)
|
|
||||||
// Persist the secret in the secret storage
|
|
||||||
storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret)
|
|
||||||
// Update local states
|
|
||||||
_state.update { it.copy(signerRequired = false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun connectExternalSigner() {
|
|
||||||
val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available")
|
|
||||||
|
|
||||||
val permissions = SignerPermissions.toJson(
|
|
||||||
listOf(
|
|
||||||
SignerPermissions.signEvent(0),
|
|
||||||
SignerPermissions.signEvent(3),
|
|
||||||
SignerPermissions.signEvent(10000),
|
|
||||||
SignerPermissions.signEvent(10050),
|
|
||||||
SignerPermissions.signEvent(10063),
|
|
||||||
SignerPermissions.signEvent(22242),
|
|
||||||
SignerPermissions.signEvent(30030),
|
|
||||||
SignerPermissions.signEvent(30315),
|
|
||||||
SignerPermissions.nip44Encrypt(),
|
|
||||||
SignerPermissions.nip44Decrypt(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected")
|
|
||||||
val signer = ExternalSignerProxy(handler, result.pubkey)
|
|
||||||
|
|
||||||
// Update signer
|
|
||||||
nostr.setSigner(signer)
|
|
||||||
// Store the signer in the secret storage
|
|
||||||
storage.setSecret(KEY_USER_SIGNER, "nip55://${result.packageName}/${result.pubkey.toHex()}")
|
|
||||||
// Update local states
|
|
||||||
_state.update { it.copy(signerRequired = false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isExternalSignerAvailable(): Boolean {
|
|
||||||
return externalSignerHandler?.isAvailable() == true
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun createIdentity(
|
|
||||||
name: String,
|
|
||||||
bio: String?,
|
|
||||||
picture: ByteArray?,
|
|
||||||
contentType: String? = null
|
|
||||||
) {
|
|
||||||
val keys = Keys.generate()
|
|
||||||
val secret = keys.secretKey().toBech32()
|
|
||||||
val avatarUrl = picture?.let {
|
|
||||||
mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg")
|
|
||||||
}
|
|
||||||
// Create identity
|
|
||||||
nostr.profiles.createIdentity(keys = keys, name = name, bio = bio, picture = avatarUrl)
|
|
||||||
// Persist the secret in the secret storage
|
|
||||||
storage.setSecret(KEY_USER_SIGNER, secret)
|
|
||||||
// Update local states
|
|
||||||
_state.update { it.copy(signerRequired = false) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,19 @@
|
|||||||
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.channels.BufferOverflow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
|
||||||
abstract class BaseViewModel : ViewModel() {
|
abstract class BaseViewModel : ViewModel() {
|
||||||
|
private val _errorEvents = MutableSharedFlow<String>(
|
||||||
|
replay = 0,
|
||||||
|
extraBufferCapacity = 10,
|
||||||
|
onBufferOverflow = BufferOverflow.SUSPEND
|
||||||
|
)
|
||||||
|
val errorEvents = _errorEvents.asSharedFlow()
|
||||||
|
|
||||||
protected fun showError(message: String) {
|
protected fun showError(message: String) {
|
||||||
ErrorRepository.showError(message)
|
_errorEvents.tryEmit(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
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.channels.BufferOverflow
|
||||||
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
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
@@ -15,7 +16,6 @@ import rust.nostr.sdk.EventId
|
|||||||
import rust.nostr.sdk.Kind
|
import rust.nostr.sdk.Kind
|
||||||
import rust.nostr.sdk.KindStandard
|
import rust.nostr.sdk.KindStandard
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import rust.nostr.sdk.RelayUrl
|
|
||||||
import rust.nostr.sdk.Tag
|
import rust.nostr.sdk.Tag
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
import su.reya.coop.Room
|
import su.reya.coop.Room
|
||||||
@@ -24,34 +24,32 @@ 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 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 = _state.stateIn(
|
||||||
_state,
|
|
||||||
nostr.messages.messageSyncState
|
|
||||||
) { local, state -> local.copy(isSyncing = state.isSyncing) }.stateIn(
|
|
||||||
viewModelScope,
|
viewModelScope,
|
||||||
SharingStarted.WhileSubscribed(5000),
|
SharingStarted.WhileSubscribed(5000),
|
||||||
ChatState()
|
ChatState()
|
||||||
)
|
)
|
||||||
|
|
||||||
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
private val _newEvents = MutableSharedFlow<UnsignedEvent>(
|
||||||
|
replay = 0,
|
||||||
|
extraBufferCapacity = 100,
|
||||||
|
onBufferOverflow = BufferOverflow.SUSPEND
|
||||||
|
)
|
||||||
val newEvents = _newEvents.asSharedFlow()
|
val newEvents = _newEvents.asSharedFlow()
|
||||||
|
|
||||||
private val _sentReports = MutableSharedFlow<Map<EventId, List<RelayUrl>>>()
|
val chatRooms = state.map { it.rooms.values.sortedByDescending { it.createdAt.asSecs() } }
|
||||||
val sentReport = _sentReports.asSharedFlow()
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||||
|
|
||||||
val chatRooms = state.map { it.rooms }
|
val isSyncing = nostr.messages.messageSyncState.map { it.isSyncing }
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet())
|
|
||||||
|
|
||||||
val isSyncing = state.map { it.isSyncing }
|
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||||
|
|
||||||
val isPartialProcessedGiftWrap = state.map { it.isPartialProcessedGiftWrap }
|
val isPartialProcessedGiftWrap = state.map { it.isPartialProcessedGiftWrap }
|
||||||
@@ -69,8 +67,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,26 +78,19 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
_newEvents.emit(event)
|
_newEvents.tryEmit(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial load of rooms
|
|
||||||
refreshChatRooms()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +111,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 +122,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,34 +131,36 @@ 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() {
|
fun refreshChatRooms() {
|
||||||
|
viewModelScope.launch {
|
||||||
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}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> {
|
fun loadChatRoomMessages(roomId: Long, onResult: (List<UnsignedEvent>) -> Unit) {
|
||||||
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
return nostr.messages.getChatRoomMessages(roomId)
|
onResult(nostr.messages.getChatRoomMessages(roomId))
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return emptyList()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun chatRoomConnect(roomId: Long) {
|
fun chatRoomConnect(roomId: Long) {
|
||||||
@@ -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 {
|
||||||
@@ -197,7 +191,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
|
|||||||
replies = replies,
|
replies = replies,
|
||||||
onRumorCreated = { event ->
|
onRumorCreated = { event ->
|
||||||
updateRoomList(roomId, event)
|
updateRoomList(roomId, event)
|
||||||
viewModelScope.launch { _newEvents.emit(event) }
|
viewModelScope.launch { _newEvents.tryEmit(event) }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,94 +1,35 @@
|
|||||||
package su.reya.coop.viewmodel
|
package su.reya.coop.viewmodel
|
||||||
|
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
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.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
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.first
|
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
|
||||||
import kotlinx.coroutines.flow.flow
|
|
||||||
import kotlinx.coroutines.flow.flowOf
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import rust.nostr.sdk.RelayMetadata
|
|
||||||
import rust.nostr.sdk.RelayUrl
|
|
||||||
import rust.nostr.sdk.Timestamp
|
|
||||||
import su.reya.coop.Profile
|
import su.reya.coop.Profile
|
||||||
import su.reya.coop.nostr.Nostr
|
import su.reya.coop.nostr.Nostr
|
||||||
import su.reya.coop.repository.MediaRepository
|
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
|
|
||||||
data class NostrAppState(
|
|
||||||
val isBusy: Boolean = false,
|
|
||||||
val isRelayListEmpty: Boolean = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
|
class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
|
||||||
private val mediaRepository = MediaRepository()
|
|
||||||
|
|
||||||
private val alwaysRunTasks = flow {
|
|
||||||
coroutineScope {
|
|
||||||
val observerJob = launch { runObserver() }
|
|
||||||
val batchingJob = launch { runMetadataBatching() }
|
|
||||||
try {
|
|
||||||
emit(Unit)
|
|
||||||
awaitCancellation()
|
|
||||||
} finally {
|
|
||||||
observerJob.cancel()
|
|
||||||
batchingJob.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val _appState = MutableStateFlow(NostrAppState())
|
|
||||||
val appState: StateFlow<NostrAppState> =
|
|
||||||
combine(_appState, alwaysRunTasks) { state, _ -> state }.stateIn(
|
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.WhileSubscribed(5000),
|
|
||||||
initialValue = NostrAppState()
|
|
||||||
)
|
|
||||||
|
|
||||||
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
|
|
||||||
val contactList = _contactList.asStateFlow()
|
|
||||||
|
|
||||||
private val profilesMutex = Mutex()
|
private val profilesMutex = Mutex()
|
||||||
private val profiles = mutableMapOf<PublicKey, MutableStateFlow<Profile?>>()
|
private val profiles = mutableMapOf<PublicKey, MutableStateFlow<Profile?>>()
|
||||||
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
|
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
|
||||||
private val seenPublicKeys = mutableSetOf<PublicKey>()
|
private val seenPublicKeys = mutableSetOf<PublicKey>()
|
||||||
|
|
||||||
val isRelayListEmpty = appState.map { it.isRelayListEmpty }
|
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
val currentUserProfile = nostr.signer.publicKeyFlow
|
|
||||||
.flatMapLatest { pubkey ->
|
|
||||||
if (pubkey != null) getMetadata(pubkey) else flowOf(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()
|
||||||
|
|
||||||
// Observe the signer state and verify the relay list
|
|
||||||
observeSignerAndCheckRelays()
|
|
||||||
|
|
||||||
// Get all local stored metadata
|
// Get all local stored metadata
|
||||||
getCacheMetadata()
|
getCacheMetadata()
|
||||||
}
|
}
|
||||||
@@ -101,13 +42,6 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun runObserver() = coroutineScope {
|
private suspend fun runObserver() = coroutineScope {
|
||||||
// Observe contact list updates
|
|
||||||
launch {
|
|
||||||
nostr.profiles.contactListUpdates.collect { contacts ->
|
|
||||||
_contactList.value = contacts.toSet()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Observe metadata updates
|
// Observe metadata updates
|
||||||
launch {
|
launch {
|
||||||
nostr.profiles.metadataUpdates.collect { (pubkey, metadata) ->
|
nostr.profiles.metadataUpdates.collect { (pubkey, metadata) ->
|
||||||
@@ -116,7 +50,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,51 +92,29 @@ 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() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
// Wait until the client is ready
|
|
||||||
nostr.waitUntilInitialized()
|
|
||||||
|
|
||||||
// Wait until a signer is explicitly set (which updates publicKeyFlow)
|
|
||||||
val currentUser = nostr.signer.publicKeyFlow.filterNotNull().first()
|
|
||||||
|
|
||||||
// Get all metadata for the current user
|
|
||||||
nostr.profiles.getUserMetadata()
|
|
||||||
|
|
||||||
// Small delay to ensure all relays are connected
|
|
||||||
delay(2.seconds)
|
|
||||||
|
|
||||||
// Check if the relay list is empty
|
|
||||||
val relays = nostr.relays.getMsgRelays(currentUser)
|
|
||||||
if (relays.isEmpty()) _appState.update { it.copy(isRelayListEmpty = true) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) }
|
||||||
@@ -211,234 +123,4 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
|
|||||||
return flow.asStateFlow()
|
return flow.asStateFlow()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun resetInternalState() {
|
|
||||||
_contactList.value = emptySet()
|
|
||||||
_appState.update {
|
|
||||||
it.copy(
|
|
||||||
isRelayListEmpty = false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dismissRelayWarning() {
|
|
||||||
_appState.update { it.copy(isRelayListEmpty = false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun updateProfile(
|
|
||||||
name: String? = null,
|
|
||||||
bio: String? = null,
|
|
||||||
picture: ByteArray? = null,
|
|
||||||
contentType: String? = null
|
|
||||||
) {
|
|
||||||
_appState.update { it.copy(isBusy = true) }
|
|
||||||
try {
|
|
||||||
val avatarUrl =
|
|
||||||
picture?.let {
|
|
||||||
mediaRepository.blossomUpload(
|
|
||||||
nostr.signer.get(),
|
|
||||||
it,
|
|
||||||
contentType ?: "image/jpeg"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl)
|
|
||||||
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
|
||||||
|
|
||||||
// Update the metadata state after successfully published
|
|
||||||
updateMetadata(currentUser, Profile(currentUser, newMetadata))
|
|
||||||
|
|
||||||
// Update local state
|
|
||||||
_appState.update { it.copy(isBusy = false) }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun refetchMsgRelays() {
|
|
||||||
val currentUser = nostr.signer.getPublicKeyAsync() ?: return
|
|
||||||
val relays = nostr.relays.fetchMsgRelays(currentUser)
|
|
||||||
|
|
||||||
if (relays.isNotEmpty()) dismissRelayWarning()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun useDefaultMsgRelayList() {
|
|
||||||
try {
|
|
||||||
val defaultRelays = nostr.relays.getDefaultMsgRelayList()
|
|
||||||
nostr.relays.setMsgRelays(defaultRelays)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun currentUserRelayList(): Map<RelayUrl, RelayMetadata?> {
|
|
||||||
try {
|
|
||||||
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
|
||||||
return nostr.relays.getRelayList(currentUser)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
return emptyMap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun addInboxRelay(relay: String) {
|
|
||||||
try {
|
|
||||||
val relayUrl = RelayUrl.parse(relay)
|
|
||||||
val relays = currentUserRelayList().toMutableMap()
|
|
||||||
relays[relayUrl] = RelayMetadata.WRITE
|
|
||||||
|
|
||||||
nostr.relays.setRelaylist(relays)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun addOutboxRelay(relay: String) {
|
|
||||||
try {
|
|
||||||
val relayUrl = RelayUrl.parse(relay)
|
|
||||||
val relays = currentUserRelayList().toMutableMap()
|
|
||||||
relays[relayUrl] = RelayMetadata.READ
|
|
||||||
|
|
||||||
nostr.relays.setRelaylist(relays)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun removeRelay(relay: String) {
|
|
||||||
try {
|
|
||||||
val relayUrl = RelayUrl.parse(relay)
|
|
||||||
val relays = currentUserRelayList().toMutableMap()
|
|
||||||
relays.remove(relayUrl)
|
|
||||||
|
|
||||||
nostr.relays.setRelaylist(relays)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun currentUserMsgRelayList(): List<RelayUrl> {
|
|
||||||
try {
|
|
||||||
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
|
||||||
return nostr.relays.getMsgRelays(currentUser)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
return emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun addMsgRelay(relay: String) {
|
|
||||||
try {
|
|
||||||
val relayUrl = RelayUrl.parse(relay)
|
|
||||||
val relays = currentUserMsgRelayList().toMutableSet()
|
|
||||||
relays.add(relayUrl)
|
|
||||||
|
|
||||||
nostr.relays.setMsgRelays(relays.toList())
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun removeMsgRelay(relay: String) {
|
|
||||||
try {
|
|
||||||
val relayUrl = RelayUrl.parse(relay)
|
|
||||||
val relays = currentUserMsgRelayList().toMutableSet()
|
|
||||||
relays.remove(relayUrl)
|
|
||||||
|
|
||||||
nostr.relays.setMsgRelays(relays.toList())
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun newContact(publicKey: PublicKey) {
|
|
||||||
if (publicKey in contactList.value) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
val updated = contactList.value + publicKey
|
|
||||||
// Publish new event
|
|
||||||
nostr.profiles.setContactList(updated.toList())
|
|
||||||
// Optimistic local update
|
|
||||||
_contactList.update { it + publicKey }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun addContact(address: String): Boolean {
|
|
||||||
val pubkey = try {
|
|
||||||
if (address.contains("@")) {
|
|
||||||
nostr.profiles.searchByAddress(address)
|
|
||||||
} else {
|
|
||||||
PublicKey.parse(address)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Invalid contact address: ${e.message}")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return run {
|
|
||||||
newContact(pubkey)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun removeContact(publicKey: PublicKey) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
if (publicKey !in contactList.value) return@launch
|
|
||||||
|
|
||||||
try {
|
|
||||||
val updated = contactList.value - publicKey
|
|
||||||
// Publish new event
|
|
||||||
nostr.profiles.setContactList(updated.toList())
|
|
||||||
// Optimistic local update
|
|
||||||
_contactList.update { it - publicKey }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun searchByAddress(query: String): PublicKey? {
|
|
||||||
try {
|
|
||||||
return nostr.profiles.searchByAddress(query)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun searchByNostr(query: String): List<PublicKey> {
|
|
||||||
try {
|
|
||||||
return nostr.profiles.searchByNostr(query)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
return emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun verifyActivity(pubkey: PublicKey): Timestamp? {
|
|
||||||
return try {
|
|
||||||
nostr.profiles.verifyActivity(pubkey)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun verifyContact(pubkey: PublicKey): Boolean {
|
|
||||||
return try {
|
|
||||||
nostr.profiles.verifyContact(pubkey)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun mutualContacts(pubkey: PublicKey): Set<PublicKey> {
|
|
||||||
return try {
|
|
||||||
nostr.profiles.mutualContacts(pubkey)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
setOf()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package su.reya.coop.viewmodel.account
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import rust.nostr.sdk.AsyncNostrSigner
|
||||||
|
import rust.nostr.sdk.EncryptedSecretKey
|
||||||
|
import rust.nostr.sdk.Keys
|
||||||
|
import rust.nostr.sdk.NostrConnect
|
||||||
|
import rust.nostr.sdk.NostrConnectUri
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import su.reya.coop.AppStorage
|
||||||
|
import su.reya.coop.nostr.ExternalSignerHandler
|
||||||
|
import su.reya.coop.nostr.ExternalSignerProxy
|
||||||
|
import su.reya.coop.nostr.Nostr
|
||||||
|
import su.reya.coop.nostr.SignerPermissions
|
||||||
|
import su.reya.coop.repository.MediaRepository
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
data class AccountState(
|
||||||
|
val signerRequired: Boolean? = null,
|
||||||
|
val isNotificationBannerDismissed: Boolean = false,
|
||||||
|
val isImporting: Boolean = false,
|
||||||
|
val importError: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
class AccountAuthDelegate(
|
||||||
|
private val nostr: Nostr,
|
||||||
|
private val storage: AppStorage,
|
||||||
|
private val mediaRepository: MediaRepository,
|
||||||
|
private val externalSignerHandler: ExternalSignerHandler? = null,
|
||||||
|
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||||
|
private val onError: (String) -> Unit,
|
||||||
|
private val onSignerReady: () -> Unit,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private const val KEY_USER_SIGNER = "user_signer"
|
||||||
|
private const val KEY_APP_KEYS = "app_keys"
|
||||||
|
private const val KEY_BANNER_DISMISSED = "notification_banner_dismissed"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(AccountState())
|
||||||
|
val state: StateFlow<AccountState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
fun init() {
|
||||||
|
checkNotificationBannerDismissedStatus()
|
||||||
|
login()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkNotificationBannerDismissedStatus() {
|
||||||
|
scope.launch {
|
||||||
|
val dismissed = storage.get(KEY_BANNER_DISMISSED) == "true"
|
||||||
|
_state.update { it.copy(isNotificationBannerDismissed = dismissed) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun login() {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val secret = withTimeoutOrNull(5.seconds) {
|
||||||
|
storage.getSecret(KEY_USER_SIGNER)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (secret == null) {
|
||||||
|
_state.update { it.copy(signerRequired = true) }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
val (signer, _) = createSigner(secret)
|
||||||
|
nostr.setSigner(signer)
|
||||||
|
}.onSuccess {
|
||||||
|
_state.update { it.copy(signerRequired = false) }
|
||||||
|
onSignerReady()
|
||||||
|
}.onFailure { e ->
|
||||||
|
onError("Login failed: ${e.message}")
|
||||||
|
_state.update { it.copy(signerRequired = true) }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onError("Login failed: ${e.message}")
|
||||||
|
_state.update { it.copy(signerRequired = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout(onLogout: () -> Unit = {}) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
nostr.signer.switch(Keys.generate())
|
||||||
|
nostr.prune()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onError("Logout encountered an error: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
storage.clear(KEY_USER_SIGNER)
|
||||||
|
storage.clear(KEY_BANNER_DISMISSED)
|
||||||
|
onLogout()
|
||||||
|
_state.update { it.copy(signerRequired = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissNotificationBanner() {
|
||||||
|
scope.launch {
|
||||||
|
storage.set(KEY_BANNER_DISMISSED, "true")
|
||||||
|
_state.update { it.copy(isNotificationBannerDismissed = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getOrInitAppKeys(): Keys = withContext(defaultDispatcher) {
|
||||||
|
val secret = storage.getSecret(KEY_APP_KEYS)
|
||||||
|
if (secret != null) return@withContext Keys.parse(secret)
|
||||||
|
val keys = Keys.generate()
|
||||||
|
storage.setSecret(KEY_APP_KEYS, keys.secretKey().toBech32())
|
||||||
|
keys
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun createSigner(
|
||||||
|
secret: String,
|
||||||
|
password: String? = null
|
||||||
|
): Pair<AsyncNostrSigner, String?> = withContext(defaultDispatcher) {
|
||||||
|
when {
|
||||||
|
secret.startsWith("nsec1") -> Keys.parse(secret) to null
|
||||||
|
|
||||||
|
secret.startsWith("ncryptsec1") -> {
|
||||||
|
if (password == null) throw IllegalArgumentException("Password is required")
|
||||||
|
val enc = EncryptedSecretKey.fromBech32(secret)
|
||||||
|
val decrypted = enc.decrypt(password)
|
||||||
|
val keys = Keys(decrypted)
|
||||||
|
keys to keys.secretKey().toBech32()
|
||||||
|
}
|
||||||
|
|
||||||
|
secret.startsWith("bunker://") -> {
|
||||||
|
val appKeys = getOrInitAppKeys()
|
||||||
|
val bunker = NostrConnectUri.parse(secret)
|
||||||
|
val timeout = 50.seconds
|
||||||
|
NostrConnect(uri = bunker, appKeys, timeout, null) to null
|
||||||
|
}
|
||||||
|
|
||||||
|
secret.startsWith("nip55://") -> {
|
||||||
|
val handler = externalSignerHandler ?: throw IllegalStateException("Not available")
|
||||||
|
val parts = secret.removePrefix("nip55://").split("/", limit = 2)
|
||||||
|
val packageName = parts[0]
|
||||||
|
val pubkey = PublicKey.parse(parts[1])
|
||||||
|
|
||||||
|
handler.setPackageName(packageName)
|
||||||
|
ExternalSignerProxy(handler, pubkey) to null
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> throw IllegalArgumentException("Invalid secret format")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isExternalSignerAvailable(): Boolean {
|
||||||
|
return externalSignerHandler?.isAvailable() == true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun importIdentity(secret: String, password: String? = null) {
|
||||||
|
scope.launch {
|
||||||
|
_state.update { it.copy(isImporting = true, importError = null) }
|
||||||
|
try {
|
||||||
|
val (signer, decryptedSecret) = createSigner(secret, password)
|
||||||
|
|
||||||
|
nostr.setSigner(signer)
|
||||||
|
onSignerReady()
|
||||||
|
|
||||||
|
storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret)
|
||||||
|
_state.update { it.copy(signerRequired = false, isImporting = false) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onError("Import failed: ${e.message}")
|
||||||
|
_state.update { it.copy(isImporting = false, importError = e.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun connectExternalSigner() {
|
||||||
|
scope.launch {
|
||||||
|
_state.update { it.copy(isImporting = true, importError = null) }
|
||||||
|
try {
|
||||||
|
val handler =
|
||||||
|
externalSignerHandler ?: throw IllegalStateException("Signer not available")
|
||||||
|
|
||||||
|
val permissions = SignerPermissions.toJson(
|
||||||
|
listOf(
|
||||||
|
SignerPermissions.signEvent(0),
|
||||||
|
SignerPermissions.signEvent(3),
|
||||||
|
SignerPermissions.signEvent(10000),
|
||||||
|
SignerPermissions.signEvent(10050),
|
||||||
|
SignerPermissions.signEvent(10063),
|
||||||
|
SignerPermissions.signEvent(22242),
|
||||||
|
SignerPermissions.signEvent(30030),
|
||||||
|
SignerPermissions.signEvent(30315),
|
||||||
|
SignerPermissions.nip44Encrypt(),
|
||||||
|
SignerPermissions.nip44Decrypt(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected")
|
||||||
|
val signer = ExternalSignerProxy(handler, result.pubkey)
|
||||||
|
val uri = "nip55://${result.packageName}/${result.pubkey.toHex()}"
|
||||||
|
|
||||||
|
nostr.setSigner(signer)
|
||||||
|
onSignerReady()
|
||||||
|
|
||||||
|
storage.setSecret(KEY_USER_SIGNER, uri)
|
||||||
|
_state.update { it.copy(signerRequired = false, isImporting = false) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onError("External signer connection failed: ${e.message}")
|
||||||
|
_state.update { it.copy(isImporting = false, importError = e.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createIdentity(
|
||||||
|
name: String,
|
||||||
|
bio: String?,
|
||||||
|
picture: ByteArray?,
|
||||||
|
contentType: String? = null
|
||||||
|
) {
|
||||||
|
scope.launch {
|
||||||
|
_state.update { it.copy(isImporting = true, importError = null) }
|
||||||
|
try {
|
||||||
|
val keys = Keys.generate()
|
||||||
|
val secret = keys.secretKey().toBech32()
|
||||||
|
val avatarUrl = picture?.let {
|
||||||
|
mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg")
|
||||||
|
}
|
||||||
|
|
||||||
|
nostr.profiles.createIdentity(
|
||||||
|
keys = keys,
|
||||||
|
name = name,
|
||||||
|
bio = bio,
|
||||||
|
picture = avatarUrl
|
||||||
|
)
|
||||||
|
onSignerReady()
|
||||||
|
|
||||||
|
storage.setSecret(KEY_USER_SIGNER, secret)
|
||||||
|
_state.update { it.copy(signerRequired = false, isImporting = false) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onError("Identity creation failed: ${e.message}")
|
||||||
|
_state.update { it.copy(isImporting = false, importError = e.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package su.reya.coop.viewmodel.account
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.Timestamp
|
||||||
|
import su.reya.coop.nostr.Nostr
|
||||||
|
|
||||||
|
class AccountContactDelegate(
|
||||||
|
private val nostr: Nostr,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val onError: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
|
||||||
|
val contactList: StateFlow<Set<PublicKey>> = _contactList.asStateFlow()
|
||||||
|
|
||||||
|
fun init() {
|
||||||
|
observeContactList()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
_contactList.value = emptySet()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeContactList() {
|
||||||
|
scope.launch {
|
||||||
|
nostr.waitUntilInitialized()
|
||||||
|
nostr.profiles.contactListUpdates.collect { contacts ->
|
||||||
|
_contactList.value = contacts.toSet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun newContact(publicKey: PublicKey) {
|
||||||
|
if (publicKey in contactList.value) return
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val updated = contactList.value + publicKey
|
||||||
|
nostr.profiles.setContactList(updated.toList())
|
||||||
|
_contactList.update { it + publicKey }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addContact(address: String) {
|
||||||
|
scope.launch {
|
||||||
|
val pubkey = try {
|
||||||
|
if (address.contains("@")) {
|
||||||
|
nostr.profiles.searchByAddress(address)
|
||||||
|
} else {
|
||||||
|
PublicKey.parse(address)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onError("Invalid contact address: ${e.message}")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
newContact(pubkey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeContact(publicKey: PublicKey) {
|
||||||
|
scope.launch {
|
||||||
|
if (publicKey !in contactList.value) return@launch
|
||||||
|
|
||||||
|
try {
|
||||||
|
val updated = contactList.value - publicKey
|
||||||
|
nostr.profiles.setContactList(updated.toList())
|
||||||
|
_contactList.update { it - publicKey }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun searchByAddress(query: String, onResult: (PublicKey?) -> Unit) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
onResult(nostr.profiles.searchByAddress(query))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onError("Error: ${e.message}")
|
||||||
|
onResult(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun searchByNostr(query: String, onResult: (List<PublicKey>) -> Unit) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
onResult(nostr.profiles.searchByNostr(query))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onError("Error: ${e.message}")
|
||||||
|
onResult(emptyList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun verifyActivity(pubkey: PublicKey, onResult: (Timestamp?) -> Unit) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
onResult(nostr.profiles.verifyActivity(pubkey))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onError("Error: ${e.message}")
|
||||||
|
onResult(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun verifyContact(pubkey: PublicKey, onResult: (Boolean) -> Unit) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
onResult(nostr.profiles.verifyContact(pubkey))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onError("Error: ${e.message}")
|
||||||
|
onResult(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mutualContacts(pubkey: PublicKey, onResult: (Set<PublicKey>) -> Unit) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
onResult(nostr.profiles.mutualContacts(pubkey))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onError("Error: ${e.message}")
|
||||||
|
onResult(emptySet())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package su.reya.coop.viewmodel.account
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.merge
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import su.reya.coop.Profile
|
||||||
|
import su.reya.coop.nostr.Nostr
|
||||||
|
import su.reya.coop.repository.MediaRepository
|
||||||
|
|
||||||
|
class AccountProfileDelegate(
|
||||||
|
private val nostr: Nostr,
|
||||||
|
private val mediaRepository: MediaRepository,
|
||||||
|
private val onError: (String) -> Unit,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
private val _isUpdatingProfile = MutableStateFlow(false)
|
||||||
|
val isUpdatingProfile: StateFlow<Boolean> = _isUpdatingProfile.asStateFlow()
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
val currentUserProfile: StateFlow<Profile?> = nostr.signer.publicKeyFlow
|
||||||
|
.flatMapLatest { pubkey ->
|
||||||
|
if (pubkey != null) currentUserProfileFlow(pubkey) else flowOf(null)
|
||||||
|
}
|
||||||
|
.stateIn(scope, SharingStarted.WhileSubscribed(5000), null)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
private fun currentUserProfileFlow(pubkey: PublicKey) = merge(
|
||||||
|
flow {
|
||||||
|
nostr.waitUntilInitialized()
|
||||||
|
val cached = nostr.profiles.getAllCacheMetadata()[pubkey]
|
||||||
|
if (cached != null) emit(Profile(pubkey, cached))
|
||||||
|
nostr.profiles.fetchMetadataBatch(listOf(pubkey))
|
||||||
|
},
|
||||||
|
nostr.profiles.metadataUpdates
|
||||||
|
.filter { (p, _) -> p == pubkey }
|
||||||
|
.map { (p, m) -> Profile(p, m) }
|
||||||
|
)
|
||||||
|
|
||||||
|
fun getUserMetadata() {
|
||||||
|
scope.launch {
|
||||||
|
nostr.profiles.getUserMetadata()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateProfile(
|
||||||
|
name: String? = null,
|
||||||
|
bio: String? = null,
|
||||||
|
picture: ByteArray? = null,
|
||||||
|
contentType: String? = null
|
||||||
|
) {
|
||||||
|
scope.launch {
|
||||||
|
_isUpdatingProfile.value = true
|
||||||
|
try {
|
||||||
|
val avatarUrl = picture?.let {
|
||||||
|
mediaRepository.blossomUpload(
|
||||||
|
nostr.signer.get(),
|
||||||
|
it,
|
||||||
|
contentType ?: "image/jpeg"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
nostr.profiles.updateProfile(name, bio, avatarUrl)
|
||||||
|
_isUpdatingProfile.value = false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onError("Error: ${e.message}")
|
||||||
|
_isUpdatingProfile.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package su.reya.coop.viewmodel.account
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import rust.nostr.sdk.RelayMetadata
|
||||||
|
import rust.nostr.sdk.RelayUrl
|
||||||
|
import su.reya.coop.nostr.Nostr
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
class AccountRelayDelegate(
|
||||||
|
private val nostr: Nostr,
|
||||||
|
private val onError: (String) -> Unit,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
private val _isRelayListEmpty = MutableStateFlow(false)
|
||||||
|
val isRelayListEmpty = _isRelayListEmpty.asStateFlow()
|
||||||
|
|
||||||
|
private val _currentUserRelayList = MutableStateFlow<Map<RelayUrl, RelayMetadata?>>(emptyMap())
|
||||||
|
val currentUserRelayList = _currentUserRelayList.asStateFlow()
|
||||||
|
|
||||||
|
private val _currentUserMsgRelayList = MutableStateFlow<List<RelayUrl>>(emptyList())
|
||||||
|
val currentUserMsgRelayList = _currentUserMsgRelayList.asStateFlow()
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
_isRelayListEmpty.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
fun checkRelayList() {
|
||||||
|
scope.launch {
|
||||||
|
val currentUser = nostr.signer.publicKeyFlow.filterNotNull().first()
|
||||||
|
println("user: ${currentUser.toBech32()}")
|
||||||
|
|
||||||
|
// Small delay to ensure subscription is ready
|
||||||
|
delay(6.seconds)
|
||||||
|
|
||||||
|
val relays = nostr.relays.getMsgRelays(currentUser)
|
||||||
|
if (relays.isEmpty()) _isRelayListEmpty.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissRelayWarning() {
|
||||||
|
_isRelayListEmpty.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refetchMsgRelays() {
|
||||||
|
scope.launch {
|
||||||
|
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@launch
|
||||||
|
val relays = nostr.relays.fetchMsgRelays(currentUser)
|
||||||
|
|
||||||
|
if (relays.isNotEmpty()) dismissRelayWarning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun useDefaultMsgRelayList() {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val defaultRelays = nostr.relays.getDefaultMsgRelayList()
|
||||||
|
nostr.relays.setMsgRelays(defaultRelays)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadCurrentUserRelayList() {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val currentUser =
|
||||||
|
nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
||||||
|
_currentUserRelayList.value = nostr.relays.getRelayList(currentUser)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun currentUserRelayListInternal(): Map<RelayUrl, RelayMetadata?> {
|
||||||
|
try {
|
||||||
|
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
||||||
|
return nostr.relays.getRelayList(currentUser)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onError("Error: ${e.message}")
|
||||||
|
return emptyMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addInboxRelay(relay: String) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val relayUrl = RelayUrl.parse(relay)
|
||||||
|
val relays = currentUserRelayListInternal().toMutableMap()
|
||||||
|
relays[relayUrl] = RelayMetadata.WRITE
|
||||||
|
|
||||||
|
nostr.relays.setRelaylist(relays)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addOutboxRelay(relay: String) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val relayUrl = RelayUrl.parse(relay)
|
||||||
|
val relays = currentUserRelayListInternal().toMutableMap()
|
||||||
|
relays[relayUrl] = RelayMetadata.READ
|
||||||
|
|
||||||
|
nostr.relays.setRelaylist(relays)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeRelay(relay: String) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val relayUrl = RelayUrl.parse(relay)
|
||||||
|
val relays = currentUserRelayListInternal().toMutableMap()
|
||||||
|
relays.remove(relayUrl)
|
||||||
|
|
||||||
|
nostr.relays.setRelaylist(relays)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadCurrentUserMsgRelayList() {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val currentUser =
|
||||||
|
nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
||||||
|
_currentUserMsgRelayList.value = nostr.relays.getMsgRelays(currentUser)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun currentUserMsgRelayListInternal(): List<RelayUrl> {
|
||||||
|
try {
|
||||||
|
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
||||||
|
return nostr.relays.getMsgRelays(currentUser)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onError("Error: ${e.message}")
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addMsgRelay(relay: String) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val relayUrl = RelayUrl.parse(relay)
|
||||||
|
val relays = currentUserMsgRelayListInternal().toMutableSet()
|
||||||
|
relays.add(relayUrl)
|
||||||
|
|
||||||
|
nostr.relays.setMsgRelays(relays.toList())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeMsgRelay(relay: String) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val relayUrl = RelayUrl.parse(relay)
|
||||||
|
val relays = currentUserMsgRelayListInternal().toMutableSet()
|
||||||
|
relays.remove(relayUrl)
|
||||||
|
|
||||||
|
nostr.relays.setMsgRelays(relays.toList())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package su.reya.coop.viewmodel.account
|
||||||
|
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.RelayMetadata
|
||||||
|
import rust.nostr.sdk.RelayUrl
|
||||||
|
import rust.nostr.sdk.Timestamp
|
||||||
|
import su.reya.coop.AppStorage
|
||||||
|
import su.reya.coop.Profile
|
||||||
|
import su.reya.coop.nostr.ExternalSignerHandler
|
||||||
|
import su.reya.coop.nostr.Nostr
|
||||||
|
import su.reya.coop.repository.MediaRepository
|
||||||
|
import su.reya.coop.viewmodel.BaseViewModel
|
||||||
|
|
||||||
|
class AccountViewModel(
|
||||||
|
nostr: Nostr,
|
||||||
|
storage: AppStorage,
|
||||||
|
mediaRepository: MediaRepository,
|
||||||
|
externalSignerHandler: ExternalSignerHandler? = null,
|
||||||
|
defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||||
|
) : BaseViewModel() {
|
||||||
|
private val relays = AccountRelayDelegate(
|
||||||
|
nostr = nostr,
|
||||||
|
onError = ::showError,
|
||||||
|
scope = viewModelScope,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val auth = AccountAuthDelegate(
|
||||||
|
nostr = nostr,
|
||||||
|
storage = storage,
|
||||||
|
mediaRepository = mediaRepository,
|
||||||
|
externalSignerHandler = externalSignerHandler,
|
||||||
|
defaultDispatcher = defaultDispatcher,
|
||||||
|
onError = ::showError,
|
||||||
|
onSignerReady = {
|
||||||
|
profile.getUserMetadata()
|
||||||
|
relays.checkRelayList()
|
||||||
|
},
|
||||||
|
scope = viewModelScope,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val profile = AccountProfileDelegate(
|
||||||
|
nostr = nostr,
|
||||||
|
mediaRepository = mediaRepository,
|
||||||
|
onError = ::showError,
|
||||||
|
scope = viewModelScope,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val contacts = AccountContactDelegate(
|
||||||
|
nostr = nostr,
|
||||||
|
onError = ::showError,
|
||||||
|
scope = viewModelScope,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
val state = auth.state
|
||||||
|
|
||||||
|
val currentUserProfile: StateFlow<Profile?> = profile.currentUserProfile
|
||||||
|
|
||||||
|
val isUpdatingProfile: StateFlow<Boolean> = profile.isUpdatingProfile
|
||||||
|
|
||||||
|
val contactList: StateFlow<Set<PublicKey>> = contacts.contactList
|
||||||
|
|
||||||
|
val isRelayListEmpty: StateFlow<Boolean> = relays.isRelayListEmpty
|
||||||
|
|
||||||
|
val currentUserRelayList: StateFlow<Map<RelayUrl, RelayMetadata?>> = relays.currentUserRelayList
|
||||||
|
|
||||||
|
val currentUserMsgRelayList: StateFlow<List<RelayUrl>> = relays.currentUserMsgRelayList
|
||||||
|
|
||||||
|
init {
|
||||||
|
auth.init()
|
||||||
|
contacts.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout(onLogout: () -> Unit = {}) = auth.logout(onLogout)
|
||||||
|
|
||||||
|
fun dismissNotificationBanner() = auth.dismissNotificationBanner()
|
||||||
|
|
||||||
|
fun connectExternalSigner() = auth.connectExternalSigner()
|
||||||
|
|
||||||
|
fun isExternalSignerAvailable(): Boolean = auth.isExternalSignerAvailable()
|
||||||
|
|
||||||
|
fun importIdentity(secret: String, password: String? = null) =
|
||||||
|
auth.importIdentity(secret, password)
|
||||||
|
|
||||||
|
fun createIdentity(
|
||||||
|
name: String,
|
||||||
|
bio: String? = null,
|
||||||
|
picture: ByteArray? = null,
|
||||||
|
contentType: String? = null,
|
||||||
|
) = auth.createIdentity(name, bio, picture, contentType)
|
||||||
|
|
||||||
|
fun updateProfile(
|
||||||
|
name: String? = null,
|
||||||
|
bio: String? = null,
|
||||||
|
picture: ByteArray? = null,
|
||||||
|
contentType: String? = null,
|
||||||
|
) {
|
||||||
|
profile.updateProfile(name, bio, picture, contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetInternalState() {
|
||||||
|
contacts.reset()
|
||||||
|
relays.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addContact(address: String) = contacts.addContact(address)
|
||||||
|
|
||||||
|
fun removeContact(publicKey: PublicKey) = contacts.removeContact(publicKey)
|
||||||
|
|
||||||
|
fun searchByAddress(query: String, onResult: (PublicKey?) -> Unit) =
|
||||||
|
contacts.searchByAddress(query, onResult)
|
||||||
|
|
||||||
|
fun searchByNostr(query: String, onResult: (List<PublicKey>) -> Unit) =
|
||||||
|
contacts.searchByNostr(query, onResult)
|
||||||
|
|
||||||
|
fun verifyActivity(pubkey: PublicKey, onResult: (Timestamp?) -> Unit) =
|
||||||
|
contacts.verifyActivity(pubkey, onResult)
|
||||||
|
|
||||||
|
fun verifyContact(pubkey: PublicKey, onResult: (Boolean) -> Unit) =
|
||||||
|
contacts.verifyContact(pubkey, onResult)
|
||||||
|
|
||||||
|
fun mutualContacts(pubkey: PublicKey, onResult: (Set<PublicKey>) -> Unit) =
|
||||||
|
contacts.mutualContacts(pubkey, onResult)
|
||||||
|
|
||||||
|
fun dismissRelayWarning() = relays.dismissRelayWarning()
|
||||||
|
|
||||||
|
fun refetchMsgRelays() = relays.refetchMsgRelays()
|
||||||
|
|
||||||
|
fun useDefaultMsgRelayList() = relays.useDefaultMsgRelayList()
|
||||||
|
|
||||||
|
fun loadCurrentUserRelayList() = relays.loadCurrentUserRelayList()
|
||||||
|
|
||||||
|
fun addInboxRelay(relay: String) = relays.addInboxRelay(relay)
|
||||||
|
|
||||||
|
fun addOutboxRelay(relay: String) = relays.addOutboxRelay(relay)
|
||||||
|
|
||||||
|
fun loadCurrentUserMsgRelayList() = relays.loadCurrentUserMsgRelayList()
|
||||||
|
|
||||||
|
fun addMsgRelay(relay: String) = relays.addMsgRelay(relay)
|
||||||
|
|
||||||
|
fun removeMsgRelay(relay: String) = relays.removeMsgRelay(relay)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user