revert to single viewmodel

This commit is contained in:
2026-06-30 16:46:02 +07:00
parent cd5a393a01
commit c3ef341f5d
21 changed files with 1048 additions and 1106 deletions

View File

@@ -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 = {

View File

@@ -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)

View File

@@ -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")
} }
} }

View File

@@ -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

View File

@@ -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

View File

@@ -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)
} }

View File

@@ -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(

View File

@@ -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()

View File

@@ -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)
} }
} }

View File

@@ -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) }

View File

@@ -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)

View File

@@ -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)
} }
} }

View File

@@ -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
} }
}, },

View File

@@ -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,7 +31,7 @@ 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()
} }
} }

View File

@@ -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 }
} }

View 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()
}
}
}

View File

@@ -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
}
}

View File

@@ -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()
}
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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}")
}
}
}