revert to single viewmodel
This commit is contained in:
@@ -47,30 +47,9 @@ 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.viewmodels.AccountViewModel
|
|
||||||
import su.reya.coop.viewmodels.AppViewModel
|
|
||||||
import su.reya.coop.viewmodels.ChatViewModel
|
|
||||||
import su.reya.coop.viewmodels.ProfileViewModel
|
|
||||||
import su.reya.coop.viewmodels.RelayViewModel
|
|
||||||
|
|
||||||
val LocalAppViewModel = staticCompositionLocalOf<AppViewModel> {
|
val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
|
||||||
error("No AppViewModel provided")
|
error("No NostrViewModel provided")
|
||||||
}
|
|
||||||
|
|
||||||
val LocalAccountViewModel = staticCompositionLocalOf<AccountViewModel> {
|
|
||||||
error("No AccountViewModel provided")
|
|
||||||
}
|
|
||||||
|
|
||||||
val LocalChatViewModel = staticCompositionLocalOf<ChatViewModel> {
|
|
||||||
error("No ChatViewModel provided")
|
|
||||||
}
|
|
||||||
|
|
||||||
val LocalProfileViewModel = staticCompositionLocalOf<ProfileViewModel> {
|
|
||||||
error("No ProfileViewModel provided")
|
|
||||||
}
|
|
||||||
|
|
||||||
val LocalRelayViewModel = staticCompositionLocalOf<RelayViewModel> {
|
|
||||||
error("No RelayViewModel provided")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
|
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
|
||||||
@@ -88,11 +67,7 @@ val LocalScanResult = staticCompositionLocalOf<QrScanResult> {
|
|||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun App(
|
fun App(
|
||||||
appViewModel: AppViewModel,
|
nostrViewModel: NostrViewModel,
|
||||||
accountViewModel: AccountViewModel,
|
|
||||||
chatViewModel: ChatViewModel,
|
|
||||||
profileViewModel: ProfileViewModel,
|
|
||||||
relayViewModel: RelayViewModel,
|
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val activity = context as? ComponentActivity
|
val activity = context as? ComponentActivity
|
||||||
@@ -101,7 +76,7 @@ fun App(
|
|||||||
val qrScanResult = remember { QrScanResult() }
|
val qrScanResult = remember { QrScanResult() }
|
||||||
|
|
||||||
// Get the signer required state
|
// Get the signer required state
|
||||||
val signerRequired by accountViewModel.signerRequired.collectAsStateWithLifecycle()
|
val signerRequired by nostrViewModel.signerRequired.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// Snackbar
|
// Snackbar
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
@@ -128,7 +103,7 @@ fun App(
|
|||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
appViewModel.errorEvents.collect { message ->
|
nostrViewModel.errorEvents.collect { message ->
|
||||||
snackbarHostState.showSnackbar(message)
|
snackbarHostState.showSnackbar(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,15 +146,12 @@ fun App(
|
|||||||
motionScheme = MotionScheme.expressive(),
|
motionScheme = MotionScheme.expressive(),
|
||||||
) {
|
) {
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalAppViewModel provides appViewModel,
|
LocalNostrViewModel provides nostrViewModel,
|
||||||
LocalAccountViewModel provides accountViewModel,
|
|
||||||
LocalChatViewModel provides chatViewModel,
|
|
||||||
LocalProfileViewModel provides profileViewModel,
|
|
||||||
LocalRelayViewModel provides relayViewModel,
|
|
||||||
LocalSnackbarHostState provides snackbarHostState,
|
LocalSnackbarHostState provides snackbarHostState,
|
||||||
LocalNavigator provides navigator,
|
LocalNavigator provides navigator,
|
||||||
LocalScanResult provides qrScanResult,
|
LocalScanResult provides qrScanResult,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
NavDisplay(
|
NavDisplay(
|
||||||
backStack = backStack,
|
backStack = backStack,
|
||||||
onBack = {
|
onBack = {
|
||||||
|
|||||||
@@ -22,11 +22,6 @@ import kotlinx.coroutines.coroutineScope
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import su.reya.coop.coop.storage.SecretStore
|
import su.reya.coop.coop.storage.SecretStore
|
||||||
import su.reya.coop.nostr.NostrManager
|
import su.reya.coop.nostr.NostrManager
|
||||||
import su.reya.coop.viewmodels.AccountViewModel
|
|
||||||
import su.reya.coop.viewmodels.AppViewModel
|
|
||||||
import su.reya.coop.viewmodels.ChatViewModel
|
|
||||||
import su.reya.coop.viewmodels.ProfileViewModel
|
|
||||||
import su.reya.coop.viewmodels.RelayViewModel
|
|
||||||
import kotlin.system.exitProcess
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@@ -39,31 +34,18 @@ class MainActivity : ComponentActivity() {
|
|||||||
private val secretStore = SecretStore(this@MainActivity)
|
private val secretStore = SecretStore(this@MainActivity)
|
||||||
private val androidSigner =
|
private val androidSigner =
|
||||||
AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
|
AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
|
||||||
private val appVM = AppViewModel(NostrManager.instance, secretStore)
|
private val nostrVM = NostrViewModel(NostrManager.instance, secretStore, androidSigner)
|
||||||
private val profileVM = ProfileViewModel(NostrManager.instance, appVM)
|
|
||||||
private val chatVM = ChatViewModel(NostrManager.instance, appVM)
|
|
||||||
private val accountVM =
|
|
||||||
AccountViewModel(NostrManager.instance, secretStore, appVM, androidSigner)
|
|
||||||
private val relayVM = RelayViewModel(NostrManager.instance, appVM, chatVM, profileVM)
|
|
||||||
|
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
return when {
|
return when {
|
||||||
modelClass.isAssignableFrom(AppViewModel::class.java) -> appVM
|
modelClass.isAssignableFrom(NostrViewModel::class.java) -> nostrVM
|
||||||
modelClass.isAssignableFrom(AccountViewModel::class.java) -> accountVM
|
|
||||||
modelClass.isAssignableFrom(ChatViewModel::class.java) -> chatVM
|
|
||||||
modelClass.isAssignableFrom(ProfileViewModel::class.java) -> profileVM
|
|
||||||
modelClass.isAssignableFrom(RelayViewModel::class.java) -> relayVM
|
|
||||||
else -> throw IllegalArgumentException("Unknown ViewModel class")
|
else -> throw IllegalArgumentException("Unknown ViewModel class")
|
||||||
} as T
|
} as T
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val appViewModel: AppViewModel by viewModels { factory }
|
private val nostrViewModel: NostrViewModel by viewModels { factory }
|
||||||
private val accountViewModel: AccountViewModel by viewModels { factory }
|
|
||||||
private val chatViewModel: ChatViewModel by viewModels { factory }
|
|
||||||
private val profileViewModel: ProfileViewModel by viewModels { factory }
|
|
||||||
private val relayViewModel: RelayViewModel by viewModels { factory }
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||||
@@ -102,36 +84,20 @@ 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 {
|
||||||
accountViewModel.signerRequired.value == null
|
nostrViewModel.signerRequired.value == null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bind the lifecycle of the ViewModels
|
// Bind the lifecycle of the ViewModels
|
||||||
val lifecycle = ProcessLifecycleOwner.get().lifecycle
|
nostrViewModel.bindLifecycle(ProcessLifecycleOwner.get().lifecycle)
|
||||||
lifecycle.addObserver(object : DefaultLifecycleObserver {
|
|
||||||
override fun onStart(owner: LifecycleOwner) {
|
|
||||||
appViewModel.viewModelScope.launch {
|
|
||||||
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
|
||||||
coroutineScope {
|
|
||||||
launch { chatViewModel.bindObservers() }
|
|
||||||
launch { profileViewModel.bindObservers() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
relayViewModel.observeSignerAndCheckRelays()
|
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
App(
|
App(
|
||||||
appViewModel = appViewModel,
|
nostrViewModel = nostrViewModel,
|
||||||
accountViewModel = accountViewModel,
|
|
||||||
chatViewModel = chatViewModel,
|
|
||||||
profileViewModel = profileViewModel,
|
|
||||||
relayViewModel = relayViewModel,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
setIntent(intent)
|
setIntent(intent)
|
||||||
|
|||||||
@@ -77,10 +77,8 @@ 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 rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
import su.reya.coop.LocalAccountViewModel
|
|
||||||
import su.reya.coop.LocalChatViewModel
|
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalProfileViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.nostr.Room
|
import su.reya.coop.nostr.Room
|
||||||
@@ -99,13 +97,12 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
|||||||
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 chatViewModel = LocalChatViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
// Get chat room by ID
|
// Get chat room by ID
|
||||||
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
|
val chatRooms by nostrViewModel.chatRooms.collectAsStateWithLifecycle()
|
||||||
val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } }
|
val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } }
|
||||||
|
|
||||||
// Show empty screen
|
// Show empty screen
|
||||||
@@ -125,11 +122,11 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
|||||||
|
|
||||||
|
|
||||||
val displayName by remember(room) {
|
val displayName by remember(room) {
|
||||||
room?.nameFlow(profileViewModel) ?: flowOf("Loading...")
|
room?.nameFlow(nostrViewModel) ?: flowOf("Loading...")
|
||||||
}.collectAsStateWithLifecycle("Loading...")
|
}.collectAsStateWithLifecycle("Loading...")
|
||||||
|
|
||||||
val picture by remember(room) {
|
val picture by remember(room) {
|
||||||
room?.pictureFlow(profileViewModel) ?: flowOf(null)
|
room?.pictureFlow(nostrViewModel) ?: flowOf(null)
|
||||||
}.collectAsStateWithLifecycle(null)
|
}.collectAsStateWithLifecycle(null)
|
||||||
|
|
||||||
var text by remember { mutableStateOf("") }
|
var text by remember { mutableStateOf("") }
|
||||||
@@ -154,7 +151,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
|||||||
val type = context.contentResolver.getType(uri)
|
val type = context.contentResolver.getType(uri)
|
||||||
|
|
||||||
// Send message
|
// Send message
|
||||||
chatViewModel.sendFileMessage(id, file, type)
|
nostrViewModel.sendFileMessage(id, file, type)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
snackbarHostState.showSnackbar("Error: ${e.message}")
|
snackbarHostState.showSnackbar("Error: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -167,7 +164,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
|||||||
|
|
||||||
LaunchedEffect(id) {
|
LaunchedEffect(id) {
|
||||||
// Get messages
|
// Get messages
|
||||||
val initialMessages = chatViewModel.getChatRoomMessages(id)
|
val initialMessages = nostrViewModel.getChatRoomMessages(id)
|
||||||
messages.clear()
|
messages.clear()
|
||||||
messages.addAll(initialMessages)
|
messages.addAll(initialMessages)
|
||||||
|
|
||||||
@@ -175,10 +172,10 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
|||||||
loading = false
|
loading = false
|
||||||
|
|
||||||
// Get msg relays for each member
|
// Get msg relays for each member
|
||||||
chatViewModel.chatRoomConnect(id)
|
nostrViewModel.chatRoomConnect(id)
|
||||||
|
|
||||||
// Handle new messages
|
// Handle new messages
|
||||||
chatViewModel.newEvents.collect { event ->
|
nostrViewModel.newEvents.collect { event ->
|
||||||
if (event.roomId() == id) {
|
if (event.roomId() == id) {
|
||||||
if (event.id() !in messages.map { it.id() }) {
|
if (event.id() !in messages.map { it.id() }) {
|
||||||
messages.add(0, event)
|
messages.add(0, event)
|
||||||
@@ -357,7 +354,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
|||||||
value = text,
|
value = text,
|
||||||
onValueChange = { text = it },
|
onValueChange = { text = it },
|
||||||
onSend = {
|
onSend = {
|
||||||
chatViewModel.sendMessage(id, text)
|
nostrViewModel.sendMessage(id, text)
|
||||||
text = ""
|
text = ""
|
||||||
},
|
},
|
||||||
onUpload = {
|
onUpload = {
|
||||||
@@ -377,14 +374,14 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
|||||||
fun ScreenerCard(room: Room) {
|
fun ScreenerCard(room: Room) {
|
||||||
val pubkey = room.members.firstOrNull() ?: return
|
val pubkey = room.members.firstOrNull() ?: return
|
||||||
|
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
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()) }
|
||||||
var lastActivity by remember { mutableStateOf<Timestamp?>(null) }
|
var lastActivity by remember { mutableStateOf<Timestamp?>(null) }
|
||||||
|
|
||||||
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) }
|
val metadataFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
|
||||||
val metadata by metadataFlow.collectAsStateWithLifecycle()
|
val metadata by metadataFlow.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
val profile = metadata?.asRecord()
|
||||||
@@ -394,11 +391,11 @@ fun ScreenerCard(room: Room) {
|
|||||||
LaunchedEffect(pubkey) {
|
LaunchedEffect(pubkey) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
// Check contact
|
// Check contact
|
||||||
profileViewModel.verifyContact(pubkey).let { isContact = it }
|
nostrViewModel.verifyContact(pubkey).let { isContact = it }
|
||||||
// Get mutual contacts
|
// Get mutual contacts
|
||||||
profileViewModel.mutualContacts(pubkey).let { mutualContacts = it }
|
nostrViewModel.mutualContacts(pubkey).let { mutualContacts = it }
|
||||||
// Get the last activity
|
// Get the last activity
|
||||||
profileViewModel.verifyActivity(pubkey)?.let { lastActivity = it }
|
nostrViewModel.verifyActivity(pubkey)?.let { lastActivity = it }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,9 +513,8 @@ fun DateSeparator(date: String) {
|
|||||||
fun ChatMessage(
|
fun ChatMessage(
|
||||||
rumor: UnsignedEvent
|
rumor: UnsignedEvent
|
||||||
) {
|
) {
|
||||||
val accountViewModel = LocalAccountViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val chatViewModel = LocalChatViewModel.current
|
val currentUser = nostrViewModel.nostr.signer.currentUser
|
||||||
val currentUser = accountViewModel.nostr.signer.currentUser
|
|
||||||
val isMine = rumor.author() == currentUser
|
val isMine = rumor.author() == currentUser
|
||||||
|
|
||||||
val bubbleShape = if (isMine) {
|
val bubbleShape = if (isMine) {
|
||||||
@@ -552,7 +548,7 @@ fun ChatMessage(
|
|||||||
onClick = {
|
onClick = {
|
||||||
val id = rumor.id()
|
val id = rumor.id()
|
||||||
if (id != null) {
|
if (id != null) {
|
||||||
val sent = chatViewModel.isMessageSent(id)
|
val sent = nostrViewModel.isMessageSent(id)
|
||||||
println("Sent: $sent")
|
println("Sent: $sent")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ 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.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalProfileViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
@@ -66,9 +66,9 @@ import su.reya.coop.short
|
|||||||
fun ContactListScreen() {
|
fun ContactListScreen() {
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
|
|
||||||
val contactList by profileViewModel.contactList.collectAsStateWithLifecycle()
|
val contactList by nostrViewModel.contactList.collectAsStateWithLifecycle()
|
||||||
var openAddContactDialog by remember { mutableStateOf(false) }
|
var openAddContactDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -180,7 +180,7 @@ fun ContactListScreen() {
|
|||||||
@Composable
|
@Composable
|
||||||
fun AddContactDialog(onDismissRequest: () -> Unit) {
|
fun AddContactDialog(onDismissRequest: () -> Unit) {
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
val nostrViewModel = LocalNostrViewModel.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) }
|
||||||
@@ -225,7 +225,7 @@ fun AddContactDialog(onDismissRequest: () -> Unit) {
|
|||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val success = profileViewModel.addContact(contact)
|
val success = nostrViewModel.addContact(contact)
|
||||||
if (success) onDismissRequest()
|
if (success) onDismissRequest()
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
@@ -283,8 +283,8 @@ fun ContactListItem(
|
|||||||
total: Int = 0,
|
total: Int = 0,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) }
|
val metadataFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
|
||||||
val metadata by metadataFlow.collectAsState(initial = null)
|
val metadata by metadataFlow.collectAsState(initial = null)
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
val profile = metadata?.asRecord()
|
||||||
@@ -293,7 +293,7 @@ fun ContactListItem(
|
|||||||
|
|
||||||
SegmentedListItem(
|
SegmentedListItem(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = { profileViewModel.removeContact(pubkey) },
|
onLongClick = { nostrViewModel.removeContact(pubkey) },
|
||||||
shapes = ListItemDefaults.segmentedShapes(
|
shapes = ListItemDefaults.segmentedShapes(
|
||||||
index = index,
|
index = index,
|
||||||
count = total
|
count = total
|
||||||
|
|||||||
@@ -91,12 +91,8 @@ import kotlinx.coroutines.flow.flowOf
|
|||||||
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.LocalAccountViewModel
|
|
||||||
import su.reya.coop.LocalAppViewModel
|
|
||||||
import su.reya.coop.LocalChatViewModel
|
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalProfileViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
import su.reya.coop.LocalRelayViewModel
|
|
||||||
import su.reya.coop.LocalScanResult
|
import su.reya.coop.LocalScanResult
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
@@ -117,27 +113,22 @@ fun HomeScreen() {
|
|||||||
val qrScanResult = LocalScanResult.current
|
val qrScanResult = LocalScanResult.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val clipboardManager = LocalClipboard.current
|
val clipboardManager = LocalClipboard.current
|
||||||
val appViewModel = LocalAppViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val accountViewModel = LocalAccountViewModel.current
|
|
||||||
val chatViewModel = LocalChatViewModel.current
|
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
|
||||||
val relayViewModel = LocalRelayViewModel.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 currentUser = accountViewModel.nostr.signer.currentUser ?: return
|
val currentUser = nostrViewModel.nostr.signer.currentUser ?: return
|
||||||
val currentUserProfile = profileViewModel.getMetadata(currentUser)
|
val currentUserProfile = nostrViewModel.getMetadata(currentUser)
|
||||||
|
|
||||||
val userProfile by currentUserProfile.collectAsStateWithLifecycle()
|
val userProfile by currentUserProfile.collectAsStateWithLifecycle()
|
||||||
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
|
val chatRooms by nostrViewModel.chatRooms.collectAsStateWithLifecycle()
|
||||||
val isRelayListEmpty by relayViewModel.isRelayListEmpty.collectAsStateWithLifecycle()
|
val isRelayListEmpty by nostrViewModel.isRelayListEmpty.collectAsStateWithLifecycle()
|
||||||
val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle()
|
val isSyncing by nostrViewModel.isSyncing.collectAsStateWithLifecycle()
|
||||||
val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
|
val isPartialProcessedGiftWrap by nostrViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
|
||||||
val isBannerDismissed by appViewModel.isNotificationBannerDismissed.collectAsState()
|
val isBannerDismissed by nostrViewModel.isNotificationBannerDismissed.collectAsState()
|
||||||
|
|
||||||
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) }
|
||||||
@@ -165,7 +156,7 @@ fun HomeScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
chatViewModel.getChatRooms()
|
nostrViewModel.getChatRooms()
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(qrScanResult.content) {
|
LaunchedEffect(qrScanResult.content) {
|
||||||
@@ -173,7 +164,7 @@ fun HomeScreen() {
|
|||||||
runCatching { PublicKey.parse(result) }
|
runCatching { PublicKey.parse(result) }
|
||||||
.onSuccess { pubkey ->
|
.onSuccess { pubkey ->
|
||||||
try {
|
try {
|
||||||
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
|
val roomId = nostrViewModel.createChatRoom(listOf(pubkey))
|
||||||
navigator.navigate(Screen.Chat(roomId))
|
navigator.navigate(Screen.Chat(roomId))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.message?.let { snackbarHostState.showSnackbar(it) }
|
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||||
@@ -289,7 +280,7 @@ fun HomeScreen() {
|
|||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = { appViewModel.dismissNotificationBanner() },
|
onClick = { nostrViewModel.dismissNotificationBanner() },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
) {
|
) {
|
||||||
Text(text = "Maybe later")
|
Text(text = "Maybe later")
|
||||||
@@ -330,7 +321,7 @@ fun HomeScreen() {
|
|||||||
onRefresh = {
|
onRefresh = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
isRefreshing = true
|
isRefreshing = true
|
||||||
chatViewModel.refreshChatRooms()
|
nostrViewModel.refreshChatRooms()
|
||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -400,7 +391,7 @@ fun HomeScreen() {
|
|||||||
onDismissRequest = { showBottomSheet = false },
|
onDismissRequest = { showBottomSheet = false },
|
||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
) {
|
) {
|
||||||
val pubkey = accountViewModel.nostr.signer.currentUser
|
val pubkey = nostrViewModel.nostr.signer.currentUser
|
||||||
val shortPubkey = pubkey?.short() ?: "Not available"
|
val shortPubkey = pubkey?.short() ?: "Not available"
|
||||||
|
|
||||||
val userName =
|
val userName =
|
||||||
@@ -487,7 +478,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 = { relayViewModel.dismissRelayWarning() },
|
onDismissRequest = { nostrViewModel.dismissRelayWarning() },
|
||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
) {
|
) {
|
||||||
@@ -594,7 +585,7 @@ fun HomeScreen() {
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
isBusy = true
|
isBusy = true
|
||||||
try {
|
try {
|
||||||
relayViewModel.refetchMsgRelays(currentUser)
|
nostrViewModel.refetchMsgRelays(currentUser)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
snackbarHostState.showSnackbar("Failed to refresh metadata: ${e.message}")
|
snackbarHostState.showSnackbar("Failed to refresh metadata: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -614,7 +605,7 @@ fun HomeScreen() {
|
|||||||
enabled = !isBusy,
|
enabled = !isBusy,
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
relayViewModel.useDefaultMsgRelayList()
|
nostrViewModel.useDefaultMsgRelayList()
|
||||||
sheetState.hide()
|
sheetState.hide()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -637,18 +628,18 @@ fun HomeScreen() {
|
|||||||
@Composable
|
@Composable
|
||||||
fun NewRequests(requests: List<Room>) {
|
fun NewRequests(requests: List<Room>) {
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
|
|
||||||
val total = requests.size
|
val total = requests.size
|
||||||
val firstRoom = requests.getOrNull(0)
|
val firstRoom = requests.getOrNull(0)
|
||||||
val secondRoom = requests.getOrNull(1)
|
val secondRoom = requests.getOrNull(1)
|
||||||
|
|
||||||
val firstName by remember(firstRoom) {
|
val firstName by remember(firstRoom) {
|
||||||
firstRoom?.nameFlow(profileViewModel) ?: flowOf("")
|
firstRoom?.nameFlow(nostrViewModel) ?: flowOf("")
|
||||||
}.collectAsStateWithLifecycle("Loading...")
|
}.collectAsStateWithLifecycle("Loading...")
|
||||||
|
|
||||||
val secondName by remember(secondRoom) {
|
val secondName by remember(secondRoom) {
|
||||||
secondRoom?.nameFlow(profileViewModel) ?: flowOf("")
|
secondRoom?.nameFlow(nostrViewModel) ?: flowOf("")
|
||||||
}.collectAsStateWithLifecycle("")
|
}.collectAsStateWithLifecycle("")
|
||||||
|
|
||||||
val supportingText = when {
|
val supportingText = when {
|
||||||
@@ -720,9 +711,9 @@ fun NewRequests(requests: List<Room>) {
|
|||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatRoom(room: Room, onClick: () -> Unit) {
|
fun ChatRoom(room: Room, onClick: () -> Unit) {
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val displayName by remember(room) { room.nameFlow(profileViewModel) }.collectAsStateWithLifecycle("Loading...")
|
val displayName by remember(room) { room.nameFlow(nostrViewModel) }.collectAsStateWithLifecycle("Loading...")
|
||||||
val picture by remember(room) { room.pictureFlow(profileViewModel) }.collectAsStateWithLifecycle(null)
|
val picture by remember(room) { room.pictureFlow(nostrViewModel) }.collectAsStateWithLifecycle(null)
|
||||||
|
|
||||||
ListItem(
|
ListItem(
|
||||||
modifier = Modifier.clickable(onClick = onClick),
|
modifier = Modifier.clickable(onClick = onClick),
|
||||||
@@ -768,7 +759,7 @@ fun BottomMenuList(
|
|||||||
onDismiss: (suspend () -> Unit) -> Unit
|
onDismiss: (suspend () -> Unit) -> Unit
|
||||||
) {
|
) {
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val accountViewModel = LocalAccountViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
|
|
||||||
val defaultMenuList = listOf(
|
val defaultMenuList = listOf(
|
||||||
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
|
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
|
||||||
@@ -798,7 +789,7 @@ fun BottomMenuList(
|
|||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.size(16.dp))
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = { onDismiss { accountViewModel.logout() } },
|
onClick = { onDismiss { nostrViewModel.logout() } },
|
||||||
colors = ButtonDefaults.filledTonalButtonColors(
|
colors = ButtonDefaults.filledTonalButtonColors(
|
||||||
containerColor = MaterialTheme.colorScheme.error,
|
containerColor = MaterialTheme.colorScheme.error,
|
||||||
contentColor = MaterialTheme.colorScheme.onError
|
contentColor = MaterialTheme.colorScheme.onError
|
||||||
|
|||||||
@@ -58,10 +58,8 @@ 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 rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import su.reya.coop.LocalAccountViewModel
|
|
||||||
import su.reya.coop.LocalAppViewModel
|
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalProfileViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
import su.reya.coop.LocalScanResult
|
import su.reya.coop.LocalScanResult
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
@@ -76,19 +74,17 @@ 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 appViewModel = LocalAppViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val accountViewModel = LocalAccountViewModel.current
|
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val isBusy by appViewModel.isBusy.collectAsStateWithLifecycle(false)
|
val isBusy by nostrViewModel.isBusy.collectAsStateWithLifecycle(false)
|
||||||
var secret by remember { mutableStateOf("") }
|
var secret by remember { mutableStateOf("") }
|
||||||
var pubkey by remember { mutableStateOf<PublicKey?>(null) }
|
var pubkey by remember { mutableStateOf<PublicKey?>(null) }
|
||||||
|
|
||||||
// Get metadata when pubkey changes
|
// Get metadata when pubkey changes
|
||||||
val metadata by remember(pubkey) {
|
val metadata by remember(pubkey) {
|
||||||
pubkey?.let(profileViewModel::getMetadata) ?: flowOf(null)
|
pubkey?.let(nostrViewModel::getMetadata) ?: flowOf(null)
|
||||||
}.collectAsStateWithLifecycle(null)
|
}.collectAsStateWithLifecycle(null)
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
val profile = metadata?.asRecord()
|
||||||
@@ -250,10 +246,10 @@ fun ImportScreen() {
|
|||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (pubkey == null) {
|
if (pubkey == null) {
|
||||||
accountViewModel.verifyIdentity(secret).let { pubkey = it }
|
nostrViewModel.verifyIdentity(secret).let { pubkey = it }
|
||||||
} else {
|
} else {
|
||||||
// Import the identity
|
// Import the identity
|
||||||
accountViewModel.importIdentity(secret)
|
nostrViewModel.importIdentity(secret)
|
||||||
// Navigate to the home screen
|
// Navigate to the home screen
|
||||||
navigator.navigate(Screen.Home)
|
navigator.navigate(Screen.Home)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,15 +19,15 @@ 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 accountViewModel = LocalAccountViewModel.current
|
val accountViewModel = LocalNostrViewModel.current
|
||||||
val currentUser = accountViewModel.nostr.signer.currentUser ?: return
|
val currentUser = accountViewModel.nostr.signer.currentUser ?: return
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
|||||||
@@ -55,9 +55,8 @@ 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.LocalChatViewModel
|
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalProfileViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
import su.reya.coop.LocalScanResult
|
import su.reya.coop.LocalScanResult
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
@@ -71,10 +70,9 @@ fun NewChatScreen() {
|
|||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val qrScanResult = LocalScanResult.current
|
val qrScanResult = LocalScanResult.current
|
||||||
val chatViewModel = LocalChatViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
|
||||||
|
|
||||||
val contactList by profileViewModel.contactList.collectAsStateWithLifecycle()
|
val contactList by nostrViewModel.contactList.collectAsStateWithLifecycle()
|
||||||
var query by remember { mutableStateOf("") }
|
var query by remember { mutableStateOf("") }
|
||||||
|
|
||||||
val createGroup = remember { mutableStateOf(false) }
|
val createGroup = remember { mutableStateOf(false) }
|
||||||
@@ -96,12 +94,12 @@ fun NewChatScreen() {
|
|||||||
selectedReceivers.add(pubkey)
|
selectedReceivers.add(pubkey)
|
||||||
}
|
}
|
||||||
} else if (query.contains("@")) {
|
} else if (query.contains("@")) {
|
||||||
val pubkey = profileViewModel.searchByAddress(query)
|
val pubkey = nostrViewModel.searchByAddress(query)
|
||||||
if (pubkey != null) {
|
if (pubkey != null) {
|
||||||
selectedReceivers.add(pubkey)
|
selectedReceivers.add(pubkey)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val results = profileViewModel.searchByNostr(query)
|
val results = nostrViewModel.searchByNostr(query)
|
||||||
searchResults.clear()
|
searchResults.clear()
|
||||||
searchResults.addAll(results)
|
searchResults.addAll(results)
|
||||||
}
|
}
|
||||||
@@ -171,7 +169,7 @@ fun NewChatScreen() {
|
|||||||
) {
|
) {
|
||||||
ExtendedFloatingActionButton(
|
ExtendedFloatingActionButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
val roomId = chatViewModel.createChatRoom(selectedReceivers.toList())
|
val roomId = nostrViewModel.createChatRoom(selectedReceivers.toList())
|
||||||
navigator.navigate(Screen.Chat(roomId))
|
navigator.navigate(Screen.Chat(roomId))
|
||||||
},
|
},
|
||||||
expanded = false,
|
expanded = false,
|
||||||
@@ -262,7 +260,7 @@ fun NewChatScreen() {
|
|||||||
items = searchResults,
|
items = searchResults,
|
||||||
selectedReceivers = selectedReceivers,
|
selectedReceivers = selectedReceivers,
|
||||||
onContactClick = { pubkey ->
|
onContactClick = { pubkey ->
|
||||||
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
|
val roomId = nostrViewModel.createChatRoom(listOf(pubkey))
|
||||||
navigator.navigate(Screen.Chat(roomId))
|
navigator.navigate(Screen.Chat(roomId))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -273,7 +271,7 @@ fun NewChatScreen() {
|
|||||||
items = contactList.toList(),
|
items = contactList.toList(),
|
||||||
selectedReceivers = selectedReceivers,
|
selectedReceivers = selectedReceivers,
|
||||||
onContactClick = { pubkey ->
|
onContactClick = { pubkey ->
|
||||||
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
|
val roomId = nostrViewModel.createChatRoom(listOf(pubkey))
|
||||||
navigator.navigate(Screen.Chat(roomId))
|
navigator.navigate(Screen.Chat(roomId))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -290,8 +288,8 @@ fun ReceiverChip(
|
|||||||
pubkey: PublicKey,
|
pubkey: PublicKey,
|
||||||
onRemove: () -> Unit
|
onRemove: () -> Unit
|
||||||
) {
|
) {
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) }
|
val metadataFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
|
||||||
val metadata by metadataFlow.collectAsState(initial = null)
|
val metadata by metadataFlow.collectAsState(initial = null)
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
val profile = metadata?.asRecord()
|
||||||
@@ -376,8 +374,8 @@ fun ContactListItem(
|
|||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit
|
onLongClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) }
|
val metadataFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
|
||||||
val metadata by metadataFlow.collectAsState(initial = null)
|
val metadata by metadataFlow.collectAsState(initial = null)
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
val profile = metadata?.asRecord()
|
||||||
|
|||||||
@@ -5,21 +5,17 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import su.reya.coop.LocalAccountViewModel
|
|
||||||
import su.reya.coop.LocalAppViewModel
|
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalProfileViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
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 appViewModel = LocalAppViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val accountViewModel = LocalAccountViewModel.current
|
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val isBusy by appViewModel.isBusy.collectAsStateWithLifecycle(false)
|
val isBusy by nostrViewModel.isBusy.collectAsStateWithLifecycle(false)
|
||||||
|
|
||||||
ProfileEditor(
|
ProfileEditor(
|
||||||
title = "Create a new identity",
|
title = "Create a new identity",
|
||||||
@@ -28,7 +24,7 @@ fun NewIdentityScreen() {
|
|||||||
onBack = { navigator.goBack() },
|
onBack = { navigator.goBack() },
|
||||||
onConfirm = { name, bio, bytes, type ->
|
onConfirm = { name, bio, bytes, type ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
accountViewModel.createIdentity(name, bio, bytes, type)
|
nostrViewModel.createIdentity(name, bio, bytes, type)
|
||||||
navigator.navigate(Screen.Home)
|
navigator.navigate(Screen.Home)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ 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 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
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.shared.getExpressiveFontFamily
|
import su.reya.coop.shared.getExpressiveFontFamily
|
||||||
@@ -57,7 +57,7 @@ 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 accountViewModel = LocalAccountViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val logoPainter = painterResource(Res.drawable.coop)
|
val logoPainter = painterResource(Res.drawable.coop)
|
||||||
@@ -158,9 +158,9 @@ fun OnboardingScreen() {
|
|||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (accountViewModel.isExternalSignerAvailable()) {
|
if (nostrViewModel.isExternalSignerAvailable()) {
|
||||||
try {
|
try {
|
||||||
accountViewModel.connectExternalSigner()
|
nostrViewModel.connectExternalSigner()
|
||||||
navigator.navigate(Screen.Home)
|
navigator.navigate(Screen.Home)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.message?.let { snackbarHostState.showSnackbar(it) }
|
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||||
|
|||||||
@@ -44,9 +44,8 @@ import coop.composeapp.generated.resources.ic_share
|
|||||||
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.LocalChatViewModel
|
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalProfileViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
@@ -59,18 +58,17 @@ fun ProfileScreen(pubkey: String) {
|
|||||||
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 chatViewModel = LocalChatViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
|
val pubkeyObj = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
|
||||||
|
|
||||||
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) }
|
val metadataFlow = remember(pubkeyObj) { nostrViewModel.getMetadata(pubkeyObj) }
|
||||||
val metadata by metadataFlow.collectAsStateWithLifecycle()
|
val metadata by metadataFlow.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
val profile = metadata?.asRecord()
|
||||||
val displayName = profile?.displayName ?: profile?.name ?: "No name"
|
val displayName = profile?.displayName ?: profile?.name ?: "No name"
|
||||||
val nip05 = profile?.nip05 ?: pubkey.short()
|
val nip05 = profile?.nip05 ?: pubkeyObj.short()
|
||||||
val picture = profile?.picture
|
val picture = profile?.picture
|
||||||
|
|
||||||
val details = remember(profile) {
|
val details = remember(profile) {
|
||||||
@@ -161,7 +159,7 @@ fun ProfileScreen(pubkey: String) {
|
|||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
|
val roomId = nostrViewModel.createChatRoom(listOf(pubkeyObj))
|
||||||
navigator.navigate(Screen.Chat(roomId))
|
navigator.navigate(Screen.Chat(roomId))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.message?.let { snackbarHostState.showSnackbar(it) }
|
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||||
@@ -191,7 +189,7 @@ fun ProfileScreen(pubkey: String) {
|
|||||||
onClick = {
|
onClick = {
|
||||||
val sendIntent = Intent().apply {
|
val sendIntent = Intent().apply {
|
||||||
action = Intent.ACTION_SEND
|
action = Intent.ACTION_SEND
|
||||||
putExtra(Intent.EXTRA_TEXT, pubkey.toBech32())
|
putExtra(Intent.EXTRA_TEXT, pubkeyObj.toBech32())
|
||||||
type = "text/plain"
|
type = "text/plain"
|
||||||
}
|
}
|
||||||
val shareIntent = Intent.createChooser(sendIntent, null)
|
val shareIntent = Intent.createChooser(sendIntent, null)
|
||||||
@@ -240,4 +238,4 @@ fun ProfileScreen(pubkey: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ import org.jetbrains.compose.resources.painterResource
|
|||||||
import rust.nostr.sdk.RelayMetadata
|
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.LocalRelayViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@@ -74,7 +74,7 @@ import su.reya.coop.LocalSnackbarHostState
|
|||||||
fun RelayScreen() {
|
fun RelayScreen() {
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val relayViewModel = LocalRelayViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val msgRelayList = remember { mutableStateListOf<RelayUrl>() }
|
val msgRelayList = remember { mutableStateListOf<RelayUrl>() }
|
||||||
@@ -96,8 +96,8 @@ fun RelayScreen() {
|
|||||||
var relayToDelete by remember { mutableStateOf<String?>(null) }
|
var relayToDelete by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
relayList.putAll(relayViewModel.currentUserRelayList())
|
relayList.putAll(nostrViewModel.currentUserRelayList())
|
||||||
msgRelayList.addAll(relayViewModel.currentUserMsgRelayList())
|
msgRelayList.addAll(nostrViewModel.currentUserMsgRelayList())
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -321,7 +321,7 @@ fun RelayScreen() {
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
relayViewModel.removeMsgRelay(relayToDelete!!)
|
nostrViewModel.removeMsgRelay(relayToDelete!!)
|
||||||
msgRelayList.removeIf { it.toString() == relayToDelete }
|
msgRelayList.removeIf { it.toString() == relayToDelete }
|
||||||
relayToDelete = null
|
relayToDelete = null
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -349,7 +349,7 @@ fun AddRelayDialog(
|
|||||||
onMsgRelayAdded: (newRelay: String) -> Unit,
|
onMsgRelayAdded: (newRelay: String) -> Unit,
|
||||||
onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit,
|
onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit,
|
||||||
) {
|
) {
|
||||||
val relayViewModel = LocalRelayViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -401,17 +401,17 @@ fun AddRelayDialog(
|
|||||||
if (!isError) {
|
if (!isError) {
|
||||||
when (selected) {
|
when (selected) {
|
||||||
"Messaging" -> {
|
"Messaging" -> {
|
||||||
relayViewModel.addMsgRelay(relayAddress)
|
nostrViewModel.addMsgRelay(relayAddress)
|
||||||
onMsgRelayAdded(relayAddress)
|
onMsgRelayAdded(relayAddress)
|
||||||
}
|
}
|
||||||
|
|
||||||
"Inbox" -> {
|
"Inbox" -> {
|
||||||
relayViewModel.addInboxRelay(relayAddress)
|
nostrViewModel.addInboxRelay(relayAddress)
|
||||||
onRelayAdded(relayAddress, RelayMetadata.WRITE)
|
onRelayAdded(relayAddress, RelayMetadata.WRITE)
|
||||||
}
|
}
|
||||||
|
|
||||||
"Outbox" -> {
|
"Outbox" -> {
|
||||||
relayViewModel.addOutboxRelay(relayAddress)
|
nostrViewModel.addOutboxRelay(relayAddress)
|
||||||
onRelayAdded(relayAddress, RelayMetadata.READ)
|
onRelayAdded(relayAddress, RelayMetadata.READ)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ 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.launch
|
import kotlinx.coroutines.launch
|
||||||
import su.reya.coop.LocalChatViewModel
|
|
||||||
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
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.nostr.RoomKind
|
import su.reya.coop.nostr.RoomKind
|
||||||
@@ -48,14 +48,14 @@ import su.reya.coop.nostr.RoomKind
|
|||||||
fun RequestListScreen() {
|
fun RequestListScreen() {
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val chatViewModel = LocalChatViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val pullToRefreshState = rememberPullToRefreshState()
|
val pullToRefreshState = rememberPullToRefreshState()
|
||||||
|
|
||||||
var isRefreshing by remember { mutableStateOf(false) }
|
var isRefreshing by remember { mutableStateOf(false) }
|
||||||
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
|
val chatRooms by nostrViewModel.chatRooms.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// Get all request rooms
|
// Get all request rooms
|
||||||
val requests = remember(chatRooms) {
|
val requests = remember(chatRooms) {
|
||||||
@@ -103,7 +103,7 @@ fun RequestListScreen() {
|
|||||||
onRefresh = {
|
onRefresh = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
isRefreshing = true
|
isRefreshing = true
|
||||||
chatViewModel.refreshChatRooms()
|
nostrViewModel.refreshChatRooms()
|
||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -155,4 +155,4 @@ fun RequestListScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,23 +5,19 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import su.reya.coop.LocalAccountViewModel
|
|
||||||
import su.reya.coop.LocalAppViewModel
|
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalProfileViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
import su.reya.coop.shared.ProfileEditor
|
import su.reya.coop.shared.ProfileEditor
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun UpdateProfileScreen() {
|
fun UpdateProfileScreen() {
|
||||||
val appViewModel = LocalAppViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val accountViewModel = LocalAccountViewModel.current
|
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val currentUser = accountViewModel.nostr.signer.currentUser ?: return
|
val currentUser = nostrViewModel.nostr.signer.currentUser ?: return
|
||||||
val metadata by profileViewModel.getMetadata(currentUser).collectAsStateWithLifecycle()
|
val metadata by nostrViewModel.getMetadata(currentUser).collectAsStateWithLifecycle()
|
||||||
val isBusy by appViewModel.isBusy.collectAsStateWithLifecycle(false)
|
val isBusy by nostrViewModel.isBusy.collectAsStateWithLifecycle(false)
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
val profile = metadata?.asRecord()
|
||||||
|
|
||||||
@@ -35,9 +31,9 @@ fun UpdateProfileScreen() {
|
|||||||
onBack = { navigator.goBack() },
|
onBack = { navigator.goBack() },
|
||||||
onConfirm = { name, bio, bytes, type ->
|
onConfirm = { name, bio, bytes, type ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
profileViewModel.updateProfile(name, bio, bytes, type)
|
nostrViewModel.updateProfile(name, bio, bytes, type)
|
||||||
navigator.goBack()
|
navigator.goBack()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import kotlinx.coroutines.flow.map
|
|||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import su.reya.coop.nostr.Room
|
import su.reya.coop.nostr.Room
|
||||||
import su.reya.coop.short
|
import su.reya.coop.short
|
||||||
import su.reya.coop.viewmodels.ProfileViewModel
|
import su.reya.coop.NostrViewModel
|
||||||
|
|
||||||
fun Room.nameFlow(
|
fun Room.nameFlow(
|
||||||
profileViewModel: ProfileViewModel,
|
nostrViewModel: NostrViewModel,
|
||||||
currentUser: PublicKey? = null
|
currentUser: PublicKey? = null
|
||||||
): Flow<String> {
|
): Flow<String> {
|
||||||
// Return early if there's a custom subject/room name
|
// Return early if there's a custom subject/room name
|
||||||
@@ -19,7 +19,7 @@ fun Room.nameFlow(
|
|||||||
val displayMembers = if (isGroup()) members.take(2) else members.take(1)
|
val displayMembers = if (isGroup()) members.take(2) else members.take(1)
|
||||||
if (displayMembers.isEmpty()) return flowOf("Unknown")
|
if (displayMembers.isEmpty()) return flowOf("Unknown")
|
||||||
|
|
||||||
return combine(displayMembers.map { profileViewModel.getMetadata(it) }) { metadataArray ->
|
return combine(displayMembers.map { nostrViewModel.getMetadata(it) }) { metadataArray ->
|
||||||
val names = metadataArray.mapIndexed { i, metadata ->
|
val names = metadataArray.mapIndexed { i, metadata ->
|
||||||
val profile = metadata?.asRecord()
|
val profile = metadata?.asRecord()
|
||||||
profile?.displayName?.takeIf { it.isNotBlank() }
|
profile?.displayName?.takeIf { it.isNotBlank() }
|
||||||
@@ -38,7 +38,8 @@ fun Room.nameFlow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Room.pictureFlow(profileViewModel: ProfileViewModel): Flow<String?> {
|
fun Room.pictureFlow(nostrViewModel: NostrViewModel): Flow<String?> {
|
||||||
val firstMember = members.firstOrNull() ?: return flowOf(null)
|
val firstMember = members.firstOrNull() ?: return flowOf(null)
|
||||||
return profileViewModel.getMetadata(firstMember).map { it?.asRecord()?.picture }
|
return nostrViewModel.getMetadata(firstMember).map { it?.asRecord()?.picture }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
920
shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt
Normal file
920
shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt
Normal file
@@ -0,0 +1,920 @@
|
|||||||
|
package su.reya.coop
|
||||||
|
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import rust.nostr.sdk.AsyncNostrSigner
|
||||||
|
import rust.nostr.sdk.EventBuilder
|
||||||
|
import rust.nostr.sdk.EventId
|
||||||
|
import rust.nostr.sdk.Keys
|
||||||
|
import rust.nostr.sdk.Kind
|
||||||
|
import rust.nostr.sdk.KindStandard
|
||||||
|
import rust.nostr.sdk.Metadata
|
||||||
|
import rust.nostr.sdk.NostrConnect
|
||||||
|
import rust.nostr.sdk.NostrConnectUri
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.RelayMetadata
|
||||||
|
import rust.nostr.sdk.RelayUrl
|
||||||
|
import rust.nostr.sdk.Tag
|
||||||
|
import rust.nostr.sdk.Timestamp
|
||||||
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
|
import su.reya.coop.blossom.BlossomClient
|
||||||
|
import su.reya.coop.nostr.ExternalSignerHandler
|
||||||
|
import su.reya.coop.nostr.ExternalSignerProxy
|
||||||
|
import su.reya.coop.nostr.Nostr
|
||||||
|
import su.reya.coop.nostr.Room
|
||||||
|
import su.reya.coop.nostr.SignerPermissions
|
||||||
|
import su.reya.coop.nostr.roomId
|
||||||
|
import su.reya.coop.storage.SecretStorage
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
class NostrViewModel(
|
||||||
|
val nostr: Nostr,
|
||||||
|
private val secretStore: SecretStorage,
|
||||||
|
private val externalSignerHandler: ExternalSignerHandler? = null,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _isNotificationBannerDismissed = MutableStateFlow(false)
|
||||||
|
val isNotificationBannerDismissed = _isNotificationBannerDismissed.asStateFlow()
|
||||||
|
|
||||||
|
private val _signerRequired = MutableStateFlow<Boolean?>(null)
|
||||||
|
val signerRequired = _signerRequired.asStateFlow()
|
||||||
|
|
||||||
|
private val _isBusy = MutableStateFlow(false)
|
||||||
|
val isBusy = _isBusy.asStateFlow()
|
||||||
|
|
||||||
|
private val _isPartialProcessedGiftWrap = MutableStateFlow(false)
|
||||||
|
val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow()
|
||||||
|
|
||||||
|
private val _isRelayListEmpty = MutableStateFlow(false)
|
||||||
|
val isRelayListEmpty = _isRelayListEmpty.asStateFlow()
|
||||||
|
|
||||||
|
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
|
||||||
|
val chatRooms = _chatRooms.asStateFlow()
|
||||||
|
|
||||||
|
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
|
||||||
|
val contactList = _contactList.asStateFlow()
|
||||||
|
|
||||||
|
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
||||||
|
val newEvents = _newEvents.asSharedFlow()
|
||||||
|
|
||||||
|
private val _sentReports = MutableSharedFlow<Map<EventId, List<RelayUrl>>>()
|
||||||
|
val sentReport = _sentReports.asSharedFlow()
|
||||||
|
|
||||||
|
private val _errorEvents = Channel<String>(Channel.BUFFERED)
|
||||||
|
val errorEvents = _errorEvents.receiveAsFlow()
|
||||||
|
|
||||||
|
private val _metadataStore = mutableMapOf<PublicKey, MutableStateFlow<Metadata?>>()
|
||||||
|
|
||||||
|
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
|
||||||
|
|
||||||
|
private val seenPublicKeys = mutableSetOf<PublicKey>()
|
||||||
|
|
||||||
|
val isSyncing = nostr.messages.messageSyncState.map { it.isSyncing }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Skip the splash screen if a user is already logged in
|
||||||
|
if (nostr.signer.currentUser != null) {
|
||||||
|
_signerRequired.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the notification banner has been dismissed
|
||||||
|
checkNotificationBannerDismissedStatus()
|
||||||
|
|
||||||
|
// Check local stored secret (secret key or bunker)
|
||||||
|
login()
|
||||||
|
|
||||||
|
// Automatically reconnect bootstrap relays
|
||||||
|
reconnect()
|
||||||
|
|
||||||
|
// Observe the signer state and verify the relay list
|
||||||
|
observeSignerAndCheckRelays()
|
||||||
|
|
||||||
|
// Get all local stored metadata
|
||||||
|
getCacheMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bindLifecycle(lifecycle: Lifecycle) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
|
coroutineScope {
|
||||||
|
launch { runObserver() }
|
||||||
|
launch { runMetadataBatching() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
|
||||||
|
// Disconnect to all bootstrap relays
|
||||||
|
viewModelScope.launch {
|
||||||
|
withContext(NonCancellable) {
|
||||||
|
nostr.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showError(message: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_errorEvents.send(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkNotificationBannerDismissedStatus() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_isNotificationBannerDismissed.value =
|
||||||
|
secretStore.get("notification_banner_dismissed") == "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reconnect() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
nostr.waitUntilInitialized()
|
||||||
|
nostr.reconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runObserver() = coroutineScope {
|
||||||
|
// Observe message sync progress
|
||||||
|
launch {
|
||||||
|
nostr.messages.messageSyncState.collect { state ->
|
||||||
|
// When at least some messages are processed, allow UI to show the list
|
||||||
|
if (state.processedCount > 0) {
|
||||||
|
_isPartialProcessedGiftWrap.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh UI every 10 messages OR when sync is fully done
|
||||||
|
if (state.processedCount % 10 == 0 || !state.isSyncing) {
|
||||||
|
refreshChatRooms()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe new messages
|
||||||
|
launch {
|
||||||
|
nostr.newEvents.collect { event ->
|
||||||
|
val roomId = event.roomId()
|
||||||
|
val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId }
|
||||||
|
|
||||||
|
if (existingRoom == null) {
|
||||||
|
val currentUser = nostr.signer.currentUser
|
||||||
|
if (currentUser != null) {
|
||||||
|
val newRoom = Room.new(event, currentUser)
|
||||||
|
_chatRooms.update { (it + newRoom).sortedDescending().toSet() }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updateRoomList(roomId, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
_newEvents.emit(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe contact list updates
|
||||||
|
launch {
|
||||||
|
nostr.profiles.contactListUpdates.collect { contacts ->
|
||||||
|
_contactList.value = contacts.toSet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe metadata updates
|
||||||
|
launch {
|
||||||
|
nostr.profiles.metadataUpdates.collect { (pubkey, metadata) ->
|
||||||
|
updateMetadata(pubkey, metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runMetadataBatching() = coroutineScope {
|
||||||
|
// Wait until the client is ready
|
||||||
|
nostr.waitUntilInitialized()
|
||||||
|
|
||||||
|
val batch = mutableSetOf<PublicKey>()
|
||||||
|
val timeout = 500L // 500ms timeout for batching
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
// Get the first pubkey
|
||||||
|
val firstKey = metadataRequestChannel.receive()
|
||||||
|
batch.add(firstKey)
|
||||||
|
|
||||||
|
// Get current time
|
||||||
|
val lastFlushTime = Clock.System.now().toEpochMilliseconds()
|
||||||
|
|
||||||
|
while (batch.isNotEmpty()) {
|
||||||
|
// Get the next pubkey
|
||||||
|
val nextKey = withTimeoutOrNull(timeout.milliseconds) {
|
||||||
|
metadataRequestChannel.receive()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only add the pubkey if it's not null
|
||||||
|
if (nextKey != null) batch.add(nextKey)
|
||||||
|
|
||||||
|
// Get current time
|
||||||
|
val now = Clock.System.now().toEpochMilliseconds()
|
||||||
|
|
||||||
|
// Check if the batch is full or timeout has passed
|
||||||
|
if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) {
|
||||||
|
val keysToRequest = batch.toList()
|
||||||
|
batch.clear()
|
||||||
|
|
||||||
|
nostr.profiles.fetchMetadataBatch(keysToRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getCacheMetadata() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
// Wait until the client is ready
|
||||||
|
nostr.waitUntilInitialized()
|
||||||
|
|
||||||
|
val results = nostr.profiles.getAllCacheMetadata()
|
||||||
|
results.forEach { (pubkey, metadata) ->
|
||||||
|
// Update the metadata state
|
||||||
|
updateMetadata(pubkey, metadata)
|
||||||
|
// Update seenPublicKeys to avoid duplicate requests
|
||||||
|
seenPublicKeys.add(pubkey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun login() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val secret = withTimeoutOrNull(3.seconds) {
|
||||||
|
secretStore.get("user_signer")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (secret == null) {
|
||||||
|
_signerRequired.value = true
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
val signer = createSigner(secret)
|
||||||
|
nostr.setSigner(signer)
|
||||||
|
}.onSuccess {
|
||||||
|
_signerRequired.value = false
|
||||||
|
}.onFailure { e ->
|
||||||
|
showError("Login failed: ${e.message}")
|
||||||
|
_signerRequired.value = true
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Login failed: ${e.message}")
|
||||||
|
_signerRequired.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeSignerAndCheckRelays() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
while (true) {
|
||||||
|
val pubkey = nostr.signer.currentUser
|
||||||
|
|
||||||
|
if (pubkey != null) {
|
||||||
|
// Get chat rooms
|
||||||
|
val rooms = nostr.messages.getChatRooms() ?: emptySet()
|
||||||
|
if (rooms.isNotEmpty()) {
|
||||||
|
mergeChatRooms(rooms)
|
||||||
|
_isPartialProcessedGiftWrap.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(pubkey)
|
||||||
|
if (relays.isEmpty()) _isRelayListEmpty.value = true
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
delay(500.milliseconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestMetadata(pubkey: PublicKey) {
|
||||||
|
if (seenPublicKeys.add(pubkey)) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
metadataRequestChannel.send(pubkey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateMetadata(pubkey: PublicKey, metadata: Metadata) {
|
||||||
|
_metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }.value = metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMetadata(pubkey: PublicKey): StateFlow<Metadata?> {
|
||||||
|
val flow = _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }
|
||||||
|
if (flow.value == null) {
|
||||||
|
requestMetadata(pubkey)
|
||||||
|
}
|
||||||
|
return flow.asStateFlow()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun currentUser(): PublicKey? {
|
||||||
|
return nostr.signer.currentUser
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
_isBusy.value = true
|
||||||
|
// 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
|
||||||
|
secretStore.clear("user_signer")
|
||||||
|
secretStore.clear("notification_banner_dismissed")
|
||||||
|
|
||||||
|
// Reset all UI states
|
||||||
|
resetInternalState()
|
||||||
|
|
||||||
|
_isBusy.value = false
|
||||||
|
_signerRequired.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resetInternalState() {
|
||||||
|
_chatRooms.value = emptySet()
|
||||||
|
_contactList.value = emptySet()
|
||||||
|
_isPartialProcessedGiftWrap.value = false
|
||||||
|
_isRelayListEmpty.value = false
|
||||||
|
_isNotificationBannerDismissed.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissNotificationBanner() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
secretStore.set("notification_banner_dismissed", "true")
|
||||||
|
_isNotificationBannerDismissed.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissRelayWarning() {
|
||||||
|
_isRelayListEmpty.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getOrInitAppKeys(): Keys {
|
||||||
|
val secret = secretStore.get("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()
|
||||||
|
secretStore.set("app_keys", keys.secretKey().toBech32())
|
||||||
|
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun blossomUpload(file: ByteArray, contentType: String? = "image/jpeg"): String? {
|
||||||
|
try {
|
||||||
|
// Upload picture to Blossom
|
||||||
|
val blossom = BlossomClient(
|
||||||
|
url = "https://blossom.band",
|
||||||
|
client = HttpClient {
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
prettyPrint = true
|
||||||
|
isLenient = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
val descriptor = blossom.upload(
|
||||||
|
file = file,
|
||||||
|
contentType = contentType,
|
||||||
|
signer = nostr.signer.get()
|
||||||
|
)
|
||||||
|
|
||||||
|
return descriptor?.url
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateProfile(
|
||||||
|
name: String? = null,
|
||||||
|
bio: String? = null,
|
||||||
|
picture: ByteArray? = null,
|
||||||
|
contentType: String? = null
|
||||||
|
) {
|
||||||
|
_isBusy.value = true
|
||||||
|
try {
|
||||||
|
val avatarUrl = picture?.let { blossomUpload(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, newMetadata)
|
||||||
|
// Update local state
|
||||||
|
_isBusy.value = false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createIdentity(
|
||||||
|
name: String,
|
||||||
|
bio: String?,
|
||||||
|
picture: ByteArray?,
|
||||||
|
contentType: String? = null
|
||||||
|
) {
|
||||||
|
_isBusy.value = true
|
||||||
|
|
||||||
|
val keys = Keys.generate()
|
||||||
|
val secret = keys.secretKey().toBech32()
|
||||||
|
|
||||||
|
try {
|
||||||
|
val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") }
|
||||||
|
// Create identity
|
||||||
|
nostr.profiles.createIdentity(keys = keys, name = name, bio, picture = avatarUrl)
|
||||||
|
// Persist the secret in the secret storage
|
||||||
|
secretStore.set("user_signer", secret)
|
||||||
|
// Update local states
|
||||||
|
_isBusy.value = false
|
||||||
|
_signerRequired.value = false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun createSigner(secret: String): AsyncNostrSigner {
|
||||||
|
return when {
|
||||||
|
secret.startsWith("nsec1") -> Keys.parse(secret)
|
||||||
|
|
||||||
|
secret.startsWith("bunker://") -> {
|
||||||
|
val appKeys = getOrInitAppKeys()
|
||||||
|
val bunker = NostrConnectUri.parse(secret)
|
||||||
|
val timeout = 50.seconds // or Duration.parse("50s")
|
||||||
|
NostrConnect(uri = bunker, appKeys, timeout, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> throw IllegalArgumentException("Invalid secret format")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun verifyIdentity(secret: String): PublicKey? {
|
||||||
|
try {
|
||||||
|
val signer = createSigner(secret)
|
||||||
|
if (secret.startsWith("bunker://")) {
|
||||||
|
showError("Please approve the connection.")
|
||||||
|
}
|
||||||
|
return signer.getPublicKeyAsync()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun importIdentity(secret: String) {
|
||||||
|
_isBusy.value = true
|
||||||
|
try {
|
||||||
|
val signer = createSigner(secret)
|
||||||
|
// Update signer
|
||||||
|
nostr.setSigner(signer)
|
||||||
|
// Persist the secret in the secret storage
|
||||||
|
secretStore.set("user_signer", secret)
|
||||||
|
// Update local states
|
||||||
|
_signerRequired.value = false
|
||||||
|
_isBusy.value = false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun connectExternalSigner() {
|
||||||
|
val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available")
|
||||||
|
_isBusy.value = true
|
||||||
|
try {
|
||||||
|
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
|
||||||
|
secretStore.set("user_signer", "nip55://${result.packageName}/${result.pubkey.toHex()}")
|
||||||
|
// Update local states
|
||||||
|
_signerRequired.value = false
|
||||||
|
_isBusy.value = false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw Exception("Notice: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isExternalSignerAvailable(): Boolean {
|
||||||
|
return externalSignerHandler?.isAvailable() == true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun refetchMsgRelays(pubkey: PublicKey) {
|
||||||
|
val relays = nostr.relays.fetchMsgRelays(pubkey)
|
||||||
|
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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createChatRoom(to: List<PublicKey>): Long {
|
||||||
|
try {
|
||||||
|
if (nostr.signer.currentUser == null) throw IllegalStateException("User not signed in")
|
||||||
|
if (to.isEmpty()) throw IllegalArgumentException("At least one recipient is required")
|
||||||
|
|
||||||
|
val currentUser = nostr.signer.currentUser ?: throw Exception("User not found")
|
||||||
|
|
||||||
|
// Construct the rumor event
|
||||||
|
val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), "")
|
||||||
|
.tags(to.map { Tag.publicKey(it) })
|
||||||
|
.finalizeUnsigned(currentUser)
|
||||||
|
|
||||||
|
// Check if the room already exists
|
||||||
|
val id = rumor.roomId()
|
||||||
|
val existingRoom = _chatRooms.value.firstOrNull { it.id == id }
|
||||||
|
|
||||||
|
// If the room already exists, return its ID
|
||||||
|
if (existingRoom != null) {
|
||||||
|
return existingRoom.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a room from the rumor event
|
||||||
|
val room = Room.new(rumor, currentUser)
|
||||||
|
|
||||||
|
// Update the chat rooms state
|
||||||
|
_chatRooms.update { currentRooms ->
|
||||||
|
(currentRooms + room).sortedDescending().toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
return room.id
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalArgumentException("Failed to create room: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getChatRoom(id: Long): Room? {
|
||||||
|
return chatRooms.value.firstOrNull { it.id == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mergeChatRooms(rooms: Set<Room>) {
|
||||||
|
_chatRooms.update { currentRooms ->
|
||||||
|
val merged = currentRooms.associateBy { it.id }.toMutableMap()
|
||||||
|
// Add or update rooms from the database
|
||||||
|
rooms.forEach { room ->
|
||||||
|
merged[room.id] = room
|
||||||
|
}
|
||||||
|
// Return as a sorted set to maintain UI consistency
|
||||||
|
merged.values.sortedDescending().toSet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getChatRooms() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val rooms = nostr.messages.getChatRooms() ?: emptySet()
|
||||||
|
mergeChatRooms(rooms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun refreshChatRooms() {
|
||||||
|
try {
|
||||||
|
val rooms = nostr.messages.getChatRooms() ?: emptySet()
|
||||||
|
mergeChatRooms(rooms)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> {
|
||||||
|
try {
|
||||||
|
return nostr.messages.getChatRoomMessages(roomId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun chatRoomConnect(roomId: Long) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
||||||
|
val members = room.members
|
||||||
|
|
||||||
|
nostr.messages.chatRoomConnect(members.toList())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendMessage(roomId: Long, message: String, replies: List<EventId> = emptyList()) {
|
||||||
|
if (message.isEmpty()) {
|
||||||
|
showError("Message cannot be empty")
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
||||||
|
nostr.messages.sendMessage(
|
||||||
|
to = room.members,
|
||||||
|
content = message,
|
||||||
|
subject = room.subject,
|
||||||
|
replies = replies,
|
||||||
|
onRumorCreated = { event ->
|
||||||
|
updateRoomList(roomId, event)
|
||||||
|
viewModelScope.launch { _newEvents.emit(event) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun sendFileMessage(
|
||||||
|
roomId: Long,
|
||||||
|
file: ByteArray?,
|
||||||
|
contentType: String? = "image/jpeg",
|
||||||
|
replies: List<EventId> = emptyList()
|
||||||
|
) {
|
||||||
|
if (file == null) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
val uri = blossomUpload(file, contentType)
|
||||||
|
?: throw IllegalArgumentException("Failed to upload file")
|
||||||
|
|
||||||
|
sendMessage(roomId, uri, replies)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalArgumentException("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isMessageSent(id: EventId): Boolean {
|
||||||
|
val giftWrapId = nostr.messages.rumorMap[id]
|
||||||
|
|
||||||
|
if (giftWrapId != null) {
|
||||||
|
val isSent = nostr.messages.sentEvents[giftWrapId]?.isNotEmpty() ?: false
|
||||||
|
return isSent
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) {
|
||||||
|
_chatRooms.update { currentRooms ->
|
||||||
|
currentRooms.map { room ->
|
||||||
|
if (room.id == roomId) {
|
||||||
|
room.copy(
|
||||||
|
lastMessage = newMessage.content(),
|
||||||
|
createdAt = newMessage.createdAt()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
room
|
||||||
|
}
|
||||||
|
}.sortedDescending().toSet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
package su.reya.coop.viewmodels
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
|
||||||
import rust.nostr.sdk.AsyncNostrSigner
|
|
||||||
import rust.nostr.sdk.Keys
|
|
||||||
import rust.nostr.sdk.NostrConnect
|
|
||||||
import rust.nostr.sdk.NostrConnectUri
|
|
||||||
import rust.nostr.sdk.PublicKey
|
|
||||||
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.storage.SecretStorage
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
|
|
||||||
class AccountViewModel(
|
|
||||||
val nostr: Nostr,
|
|
||||||
private val secretStore: SecretStorage,
|
|
||||||
private val appViewModel: AppViewModel,
|
|
||||||
private val externalSignerHandler: ExternalSignerHandler? = null,
|
|
||||||
) : ViewModel() {
|
|
||||||
private val _signerRequired = MutableStateFlow<Boolean?>(null)
|
|
||||||
val signerRequired = _signerRequired.asStateFlow()
|
|
||||||
|
|
||||||
init {
|
|
||||||
// Skip the splash screen if a user is already logged in
|
|
||||||
if (nostr.signer.currentUser != null) {
|
|
||||||
_signerRequired.value = false
|
|
||||||
}
|
|
||||||
// Check local stored secret
|
|
||||||
login()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun login() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
val secret = withTimeoutOrNull(3.seconds) {
|
|
||||||
secretStore.get("user_signer")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (secret == null) {
|
|
||||||
_signerRequired.value = true
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
runCatching {
|
|
||||||
val signer = createSigner(secret)
|
|
||||||
nostr.setSigner(signer)
|
|
||||||
}.onSuccess {
|
|
||||||
_signerRequired.value = false
|
|
||||||
}.onFailure { e ->
|
|
||||||
appViewModel.showError("Login failed: ${e.message}")
|
|
||||||
_signerRequired.value = true
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Login failed: ${e.message}")
|
|
||||||
_signerRequired.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun logout() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
appViewModel.setBusy(true)
|
|
||||||
// Reset the nostr signer and prune the database
|
|
||||||
nostr.signer.switch(Keys.generate())
|
|
||||||
nostr.prune()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Logout encountered an error: ${e.message}")
|
|
||||||
} finally {
|
|
||||||
// Clear credentials from persistent storage
|
|
||||||
secretStore.clear("user_signer")
|
|
||||||
secretStore.clear("notification_banner_dismissed")
|
|
||||||
// Reset local states
|
|
||||||
appViewModel.setBusy(false)
|
|
||||||
_signerRequired.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getOrInitAppKeys(): Keys {
|
|
||||||
val secret = secretStore.get("app_keys")
|
|
||||||
if (secret != null) return Keys.parse(secret)
|
|
||||||
|
|
||||||
val keys = Keys.generate()
|
|
||||||
secretStore.set("app_keys", keys.secretKey().toBech32())
|
|
||||||
|
|
||||||
return keys
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun createSigner(secret: String): AsyncNostrSigner {
|
|
||||||
return when {
|
|
||||||
secret.startsWith("nsec1") -> Keys.parse(secret)
|
|
||||||
secret.startsWith("bunker://") -> {
|
|
||||||
val appKeys = getOrInitAppKeys()
|
|
||||||
val bunker = NostrConnectUri.parse(secret)
|
|
||||||
val timeout = 50.seconds
|
|
||||||
NostrConnect(uri = bunker, appKeys, timeout, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
secret.startsWith("nip55://") -> {
|
|
||||||
val handler = externalSignerHandler
|
|
||||||
?: throw IllegalStateException("External signer not available on this platform")
|
|
||||||
val parts = secret.removePrefix("nip55://").split("/", limit = 2)
|
|
||||||
val packageName = parts[0]
|
|
||||||
val pubkey = PublicKey.parse(parts[1])
|
|
||||||
handler.setPackageName(packageName)
|
|
||||||
ExternalSignerProxy(handler, pubkey)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> throw IllegalArgumentException("Invalid secret format")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun verifyIdentity(secret: String): PublicKey? {
|
|
||||||
try {
|
|
||||||
val signer = createSigner(secret)
|
|
||||||
if (secret.startsWith("bunker://")) {
|
|
||||||
appViewModel.showError("Please approve the connection.")
|
|
||||||
}
|
|
||||||
return signer.getPublicKeyAsync()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun importIdentity(secret: String) {
|
|
||||||
appViewModel.setBusy(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
val signer = createSigner(secret)
|
|
||||||
nostr.setSigner(signer)
|
|
||||||
secretStore.set("user_signer", secret)
|
|
||||||
_signerRequired.value = false
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
} finally {
|
|
||||||
appViewModel.setBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun createIdentity(
|
|
||||||
name: String,
|
|
||||||
bio: String?,
|
|
||||||
picture: ByteArray?,
|
|
||||||
contentType: String? = "image/jpeg",
|
|
||||||
) {
|
|
||||||
appViewModel.setBusy(true)
|
|
||||||
|
|
||||||
val keys = Keys.generate()
|
|
||||||
val secret = keys.secretKey().toBech32()
|
|
||||||
|
|
||||||
try {
|
|
||||||
val avatarUrl = picture?.let { appViewModel.blossomUpload(it, contentType) }
|
|
||||||
nostr.profiles.createIdentity(keys = keys, name = name, bio, picture = avatarUrl)
|
|
||||||
// Set credentials in persistent storage
|
|
||||||
secretStore.set("user_signer", secret)
|
|
||||||
// Update local state
|
|
||||||
_signerRequired.value = false
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
} finally {
|
|
||||||
appViewModel.setBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun connectExternalSigner() {
|
|
||||||
val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available")
|
|
||||||
appViewModel.setBusy(true)
|
|
||||||
try {
|
|
||||||
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)
|
|
||||||
nostr.setSigner(signer)
|
|
||||||
// Set credentials in persistent storage
|
|
||||||
secretStore.set("user_signer", "nip55://${result.packageName}/${result.pubkey.toHex()}")
|
|
||||||
// Update local state
|
|
||||||
_signerRequired.value = false
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw Exception("Notice: ${e.message}")
|
|
||||||
} finally {
|
|
||||||
appViewModel.setBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isExternalSignerAvailable(): Boolean {
|
|
||||||
return externalSignerHandler?.isAvailable() == true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
package su.reya.coop.viewmodels
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import io.ktor.client.HttpClient
|
|
||||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
|
||||||
import io.ktor.serialization.kotlinx.json.json
|
|
||||||
import kotlinx.coroutines.NonCancellable
|
|
||||||
import kotlinx.coroutines.channels.Channel
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import su.reya.coop.blossom.BlossomClient
|
|
||||||
import su.reya.coop.nostr.Nostr
|
|
||||||
import su.reya.coop.storage.SecretStorage
|
|
||||||
|
|
||||||
class AppViewModel(
|
|
||||||
private val nostr: Nostr,
|
|
||||||
private val secretStore: SecretStorage
|
|
||||||
) : ViewModel() {
|
|
||||||
private val _isBusy = MutableStateFlow(false)
|
|
||||||
val isBusy = _isBusy.asStateFlow()
|
|
||||||
|
|
||||||
private val _isNotificationBannerDismissed = MutableStateFlow(false)
|
|
||||||
val isNotificationBannerDismissed = _isNotificationBannerDismissed.asStateFlow()
|
|
||||||
|
|
||||||
private val _errorEvents = Channel<String>(Channel.BUFFERED)
|
|
||||||
val errorEvents = _errorEvents.receiveAsFlow()
|
|
||||||
|
|
||||||
init {
|
|
||||||
checkNotificationBannerDismissedStatus()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setBusy(busy: Boolean) {
|
|
||||||
_isBusy.value = busy
|
|
||||||
}
|
|
||||||
|
|
||||||
fun showError(message: String) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_errorEvents.send(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun checkNotificationBannerDismissedStatus() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_isNotificationBannerDismissed.value =
|
|
||||||
secretStore.get("notification_banner_dismissed") == "true"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dismissNotificationBanner() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
secretStore.set("notification_banner_dismissed", "true")
|
|
||||||
_isNotificationBannerDismissed.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun blossomUpload(file: ByteArray, contentType: String? = "image/jpeg"): String? {
|
|
||||||
val blossom = BlossomClient(
|
|
||||||
url = "https://blossom.band",
|
|
||||||
client = HttpClient {
|
|
||||||
install(ContentNegotiation) {
|
|
||||||
json(Json {
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
prettyPrint = true
|
|
||||||
isLenient = true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
val descriptor = blossom.upload(
|
|
||||||
file = file,
|
|
||||||
contentType = contentType,
|
|
||||||
signer = nostr.signer.get()
|
|
||||||
)
|
|
||||||
|
|
||||||
return descriptor?.url
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCleared() {
|
|
||||||
super.onCleared()
|
|
||||||
viewModelScope.launch {
|
|
||||||
withContext(NonCancellable) {
|
|
||||||
nostr.disconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
package su.reya.coop.viewmodels
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import kotlinx.coroutines.coroutineScope
|
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import rust.nostr.sdk.EventBuilder
|
|
||||||
import rust.nostr.sdk.EventId
|
|
||||||
import rust.nostr.sdk.Kind
|
|
||||||
import rust.nostr.sdk.KindStandard
|
|
||||||
import rust.nostr.sdk.PublicKey
|
|
||||||
import rust.nostr.sdk.Tag
|
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
|
||||||
import su.reya.coop.nostr.Nostr
|
|
||||||
import su.reya.coop.nostr.Room
|
|
||||||
import su.reya.coop.nostr.roomId
|
|
||||||
|
|
||||||
class ChatViewModel(
|
|
||||||
private val nostr: Nostr,
|
|
||||||
private val appViewModel: AppViewModel
|
|
||||||
) : ViewModel() {
|
|
||||||
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
|
|
||||||
val chatRooms = _chatRooms.asStateFlow()
|
|
||||||
|
|
||||||
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
|
||||||
val newEvents = _newEvents.asSharedFlow()
|
|
||||||
|
|
||||||
private val _isPartialProcessedGiftWrap = MutableStateFlow(false)
|
|
||||||
val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow()
|
|
||||||
|
|
||||||
val isSyncing = nostr.messages.messageSyncState.map { it.isSyncing }
|
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
|
||||||
|
|
||||||
suspend fun bindObservers() = coroutineScope {
|
|
||||||
launch {
|
|
||||||
nostr.messages.messageSyncState.collect { state ->
|
|
||||||
if (state.processedCount > 0) {
|
|
||||||
_isPartialProcessedGiftWrap.value = true
|
|
||||||
}
|
|
||||||
if (state.processedCount % 10 == 0 || !state.isSyncing) {
|
|
||||||
refreshChatRooms()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
launch {
|
|
||||||
nostr.newEvents.collect { event ->
|
|
||||||
val roomId = event.roomId()
|
|
||||||
val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId }
|
|
||||||
|
|
||||||
if (existingRoom == null) {
|
|
||||||
val currentUser = nostr.signer.currentUser
|
|
||||||
if (currentUser != null) {
|
|
||||||
val newRoom = Room.new(event, currentUser)
|
|
||||||
_chatRooms.update { (it + newRoom).sortedDescending().toSet() }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
updateRoomList(roomId, event)
|
|
||||||
}
|
|
||||||
|
|
||||||
_newEvents.emit(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createChatRoom(to: List<PublicKey>): Long {
|
|
||||||
try {
|
|
||||||
val currentUser =
|
|
||||||
nostr.signer.currentUser ?: throw IllegalStateException("User not signed in")
|
|
||||||
if (to.isEmpty()) throw IllegalArgumentException("At least one recipient is required")
|
|
||||||
|
|
||||||
val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), "")
|
|
||||||
.tags(to.map { Tag.publicKey(it) })
|
|
||||||
.finalizeUnsigned(currentUser)
|
|
||||||
|
|
||||||
val id = rumor.roomId()
|
|
||||||
val existingRoom = _chatRooms.value.firstOrNull { it.id == id }
|
|
||||||
|
|
||||||
if (existingRoom != null) {
|
|
||||||
return existingRoom.id
|
|
||||||
}
|
|
||||||
|
|
||||||
val room = Room.new(rumor, currentUser)
|
|
||||||
_chatRooms.update { currentRooms ->
|
|
||||||
(currentRooms + room).sortedDescending().toSet()
|
|
||||||
}
|
|
||||||
|
|
||||||
return room.id
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalArgumentException("Failed to create room: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getChatRoom(id: Long): Room? {
|
|
||||||
return chatRooms.value.firstOrNull { it.id == id }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getChatRooms() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val rooms = nostr.messages.getChatRooms() ?: emptySet()
|
|
||||||
mergeChatRooms(rooms)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun refreshChatRooms() {
|
|
||||||
try {
|
|
||||||
val rooms = nostr.messages.getChatRooms() ?: emptySet()
|
|
||||||
mergeChatRooms(rooms)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun mergeChatRooms(rooms: Set<Room>) {
|
|
||||||
_chatRooms.update { currentRooms ->
|
|
||||||
val merged = currentRooms.associateBy { it.id }.toMutableMap()
|
|
||||||
rooms.forEach { room ->
|
|
||||||
merged[room.id] = room
|
|
||||||
}
|
|
||||||
merged.values.sortedDescending().toSet()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> {
|
|
||||||
try {
|
|
||||||
return nostr.messages.getChatRoomMessages(roomId)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
return emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun chatRoomConnect(roomId: Long) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
|
||||||
nostr.messages.chatRoomConnect(room.members.toList())
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendMessage(
|
|
||||||
roomId: Long,
|
|
||||||
message: String,
|
|
||||||
replies: List<EventId> = emptyList()
|
|
||||||
) {
|
|
||||||
if (message.isEmpty()) {
|
|
||||||
appViewModel.showError("Message cannot be empty")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
|
||||||
nostr.messages.sendMessage(
|
|
||||||
to = room.members,
|
|
||||||
content = message,
|
|
||||||
subject = room.subject,
|
|
||||||
replies = replies,
|
|
||||||
onRumorCreated = { event ->
|
|
||||||
updateRoomList(roomId, event)
|
|
||||||
viewModelScope.launch { _newEvents.emit(event) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun sendFileMessage(
|
|
||||||
roomId: Long,
|
|
||||||
file: ByteArray?,
|
|
||||||
contentType: String? = "image/jpeg",
|
|
||||||
replies: List<EventId> = emptyList()
|
|
||||||
) {
|
|
||||||
if (file == null) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
val uri = appViewModel.blossomUpload(file, contentType)
|
|
||||||
?: throw IllegalArgumentException("Failed to upload file")
|
|
||||||
|
|
||||||
sendMessage(roomId, uri, replies)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalArgumentException("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isMessageSent(id: EventId): Boolean {
|
|
||||||
val giftWrapId = nostr.messages.rumorMap[id]
|
|
||||||
return if (giftWrapId != null) {
|
|
||||||
nostr.messages.sentEvents[giftWrapId]?.isNotEmpty() ?: false
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) {
|
|
||||||
_chatRooms.update { currentRooms ->
|
|
||||||
currentRooms.map { room ->
|
|
||||||
if (room.id == roomId) {
|
|
||||||
room.copy(
|
|
||||||
lastMessage = newMessage.content(),
|
|
||||||
createdAt = newMessage.createdAt()
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
room
|
|
||||||
}
|
|
||||||
}.sortedDescending().toSet()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
package su.reya.coop.viewmodels
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import kotlinx.coroutines.channels.Channel
|
|
||||||
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 kotlinx.coroutines.withTimeoutOrNull
|
|
||||||
import rust.nostr.sdk.Metadata
|
|
||||||
import rust.nostr.sdk.PublicKey
|
|
||||||
import rust.nostr.sdk.Timestamp
|
|
||||||
import su.reya.coop.nostr.Nostr
|
|
||||||
import kotlin.time.Clock
|
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
|
|
||||||
class ProfileViewModel(
|
|
||||||
private val nostr: Nostr,
|
|
||||||
private val appViewModel: AppViewModel
|
|
||||||
) : ViewModel() {
|
|
||||||
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
|
|
||||||
val contactList = _contactList.asStateFlow()
|
|
||||||
|
|
||||||
private val _metadataStore = mutableMapOf<PublicKey, MutableStateFlow<Metadata?>>()
|
|
||||||
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
|
|
||||||
private val seenPublicKeys = mutableSetOf<PublicKey>()
|
|
||||||
|
|
||||||
init {
|
|
||||||
getCacheMetadata()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun bindObservers() = coroutineScope {
|
|
||||||
launch {
|
|
||||||
nostr.profiles.contactListUpdates.collect { contacts ->
|
|
||||||
_contactList.value = contacts.toSet()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
launch {
|
|
||||||
nostr.profiles.metadataUpdates.collect { (pubkey, metadata) ->
|
|
||||||
updateMetadata(pubkey, metadata)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
launch { runMetadataBatching() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateMetadata(pubkey: PublicKey, metadata: Metadata) {
|
|
||||||
_metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }.value = metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getMetadata(pubkey: PublicKey): StateFlow<Metadata?> {
|
|
||||||
val flow = _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }
|
|
||||||
if (flow.value == null) {
|
|
||||||
requestMetadata(pubkey)
|
|
||||||
}
|
|
||||||
return flow.asStateFlow()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requestMetadata(pubkey: PublicKey) {
|
|
||||||
if (seenPublicKeys.add(pubkey)) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
metadataRequestChannel.send(pubkey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getCacheMetadata() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
nostr.waitUntilInitialized()
|
|
||||||
val results = nostr.profiles.getAllCacheMetadata()
|
|
||||||
results.forEach { (pubkey, metadata) ->
|
|
||||||
updateMetadata(pubkey, metadata)
|
|
||||||
seenPublicKeys.add(pubkey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun runMetadataBatching() = coroutineScope {
|
|
||||||
nostr.waitUntilInitialized()
|
|
||||||
val batch = mutableSetOf<PublicKey>()
|
|
||||||
val timeout = 500L
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
val firstKey = metadataRequestChannel.receive()
|
|
||||||
batch.add(firstKey)
|
|
||||||
val lastFlushTime = Clock.System.now().toEpochMilliseconds()
|
|
||||||
|
|
||||||
while (batch.isNotEmpty()) {
|
|
||||||
val nextKey = withTimeoutOrNull(timeout.milliseconds) {
|
|
||||||
metadataRequestChannel.receive()
|
|
||||||
}
|
|
||||||
if (nextKey != null) batch.add(nextKey)
|
|
||||||
val now = Clock.System.now().toEpochMilliseconds()
|
|
||||||
|
|
||||||
if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) {
|
|
||||||
val keysToRequest = batch.toList()
|
|
||||||
batch.clear()
|
|
||||||
nostr.profiles.fetchMetadataBatch(keysToRequest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun updateProfile(
|
|
||||||
name: String? = null,
|
|
||||||
bio: String? = null,
|
|
||||||
picture: ByteArray? = null,
|
|
||||||
contentType: String? = "image/jpeg"
|
|
||||||
) {
|
|
||||||
appViewModel.setBusy(true)
|
|
||||||
try {
|
|
||||||
val avatarUrl = picture?.let { appViewModel.blossomUpload(it, contentType) }
|
|
||||||
val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl)
|
|
||||||
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
|
||||||
|
|
||||||
updateMetadata(currentUser, newMetadata)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
} finally {
|
|
||||||
appViewModel.setBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun addContact(address: String): Boolean {
|
|
||||||
val pubkey = try {
|
|
||||||
if (address.contains("@")) {
|
|
||||||
nostr.profiles.searchByAddress(address)
|
|
||||||
} else {
|
|
||||||
PublicKey.parse(address)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Invalid contact address: ${e.message}")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pubkey in contactList.value) return true
|
|
||||||
|
|
||||||
return try {
|
|
||||||
val updated = contactList.value + pubkey
|
|
||||||
nostr.profiles.setContactList(updated.toList())
|
|
||||||
_contactList.update { it + pubkey }
|
|
||||||
true
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun removeContact(publicKey: PublicKey) {
|
|
||||||
viewModelScope.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) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun searchByAddress(query: String): PublicKey? {
|
|
||||||
try {
|
|
||||||
return nostr.profiles.searchByAddress(query)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun searchByNostr(query: String): List<PublicKey> {
|
|
||||||
try {
|
|
||||||
return nostr.profiles.searchByNostr(query)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
return emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun verifyActivity(pubkey: PublicKey): Timestamp? {
|
|
||||||
return try {
|
|
||||||
nostr.profiles.verifyActivity(pubkey)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun verifyContact(pubkey: PublicKey): Boolean {
|
|
||||||
return try {
|
|
||||||
nostr.profiles.verifyContact(pubkey)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun mutualContacts(pubkey: PublicKey): Set<PublicKey> {
|
|
||||||
return try {
|
|
||||||
nostr.profiles.mutualContacts(pubkey)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
setOf()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getUserMetadata() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
nostr.profiles.getUserMetadata()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
package su.reya.coop.viewmodels
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import rust.nostr.sdk.PublicKey
|
|
||||||
import rust.nostr.sdk.RelayMetadata
|
|
||||||
import rust.nostr.sdk.RelayUrl
|
|
||||||
import su.reya.coop.nostr.Nostr
|
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
|
|
||||||
class RelayViewModel(
|
|
||||||
private val nostr: Nostr,
|
|
||||||
private val appViewModel: AppViewModel,
|
|
||||||
private val chatViewModel: ChatViewModel,
|
|
||||||
private val profileViewModel: ProfileViewModel
|
|
||||||
) : ViewModel() {
|
|
||||||
private val _isRelayListEmpty = MutableStateFlow(false)
|
|
||||||
val isRelayListEmpty = _isRelayListEmpty.asStateFlow()
|
|
||||||
|
|
||||||
fun reconnect() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
nostr.waitUntilInitialized()
|
|
||||||
nostr.reconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun observeSignerAndCheckRelays() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
while (true) {
|
|
||||||
val pubkey = nostr.signer.currentUser
|
|
||||||
if (pubkey != null) {
|
|
||||||
val rooms = nostr.messages.getChatRooms() ?: emptySet()
|
|
||||||
if (rooms.isNotEmpty()) {
|
|
||||||
chatViewModel.mergeChatRooms(rooms)
|
|
||||||
// Note: isPartialProcessedGiftWrap is in ChatViewModel
|
|
||||||
// We might need to expose a way to set it or just let it be updated by observers
|
|
||||||
}
|
|
||||||
|
|
||||||
profileViewModel.getUserMetadata()
|
|
||||||
|
|
||||||
delay(2.seconds)
|
|
||||||
|
|
||||||
val relays = nostr.relays.getMsgRelays(pubkey)
|
|
||||||
if (relays.isEmpty()) _isRelayListEmpty.value = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
delay(500.milliseconds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun refetchMsgRelays(pubkey: PublicKey) {
|
|
||||||
val relays = nostr.relays.fetchMsgRelays(pubkey)
|
|
||||||
if (relays.isNotEmpty()) dismissRelayWarning()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dismissRelayWarning() {
|
|
||||||
_isRelayListEmpty.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun useDefaultMsgRelayList() {
|
|
||||||
try {
|
|
||||||
val defaultRelays = nostr.relays.getDefaultMsgRelayList()
|
|
||||||
nostr.relays.setMsgRelays(defaultRelays)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.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) {
|
|
||||||
appViewModel.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) {
|
|
||||||
appViewModel.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) {
|
|
||||||
appViewModel.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) {
|
|
||||||
appViewModel.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) {
|
|
||||||
appViewModel.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) {
|
|
||||||
appViewModel.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) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user