chore: re-structure project #31
@@ -47,9 +47,30 @@ import su.reya.coop.screens.RelayScreen
|
||||
import su.reya.coop.screens.RequestListScreen
|
||||
import su.reya.coop.screens.ScanScreen
|
||||
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 LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
|
||||
error("No NostrViewModel provided")
|
||||
val LocalAppViewModel = staticCompositionLocalOf<AppViewModel> {
|
||||
error("No AppViewModel 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> {
|
||||
@@ -66,14 +87,21 @@ val LocalScanResult = staticCompositionLocalOf<QrScanResult> {
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun App(viewModel: NostrViewModel) {
|
||||
fun App(
|
||||
appViewModel: AppViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
chatViewModel: ChatViewModel,
|
||||
profileViewModel: ProfileViewModel,
|
||||
relayViewModel: RelayViewModel,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val activity = context as? ComponentActivity
|
||||
val backStack = rememberNavBackStack(Screen.Home)
|
||||
val navigator = remember(backStack) { Navigator(backStack) }
|
||||
val qrScanResult = remember { QrScanResult() }
|
||||
|
||||
val signerRequired by viewModel.signerRequired.collectAsStateWithLifecycle()
|
||||
// Get the signer required state
|
||||
val signerRequired by accountViewModel.signerRequired.collectAsStateWithLifecycle()
|
||||
|
||||
// Snackbar
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
@@ -100,7 +128,7 @@ fun App(viewModel: NostrViewModel) {
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.errorEvents.collect { message ->
|
||||
appViewModel.errorEvents.collect { message ->
|
||||
snackbarHostState.showSnackbar(message)
|
||||
}
|
||||
}
|
||||
@@ -143,7 +171,11 @@ fun App(viewModel: NostrViewModel) {
|
||||
motionScheme = MotionScheme.expressive(),
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalNostrViewModel provides viewModel,
|
||||
LocalAppViewModel provides appViewModel,
|
||||
LocalAccountViewModel provides accountViewModel,
|
||||
LocalChatViewModel provides chatViewModel,
|
||||
LocalProfileViewModel provides profileViewModel,
|
||||
LocalRelayViewModel provides relayViewModel,
|
||||
LocalSnackbarHostState provides snackbarHostState,
|
||||
LocalNavigator provides navigator,
|
||||
LocalScanResult provides qrScanResult,
|
||||
|
||||
@@ -10,11 +10,23 @@ import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import su.reya.coop.coop.storage.SecretStore
|
||||
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
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
@@ -22,16 +34,37 @@ class MainActivity : ComponentActivity() {
|
||||
val externalSignerLauncher = ExternalSignerLauncher()
|
||||
}
|
||||
|
||||
private val viewModel: NostrViewModel by viewModels {
|
||||
private val factory by lazy {
|
||||
object : ViewModelProvider.Factory {
|
||||
private val secretStore = SecretStore(this@MainActivity)
|
||||
private val androidSigner =
|
||||
AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
|
||||
private val appVM = AppViewModel(NostrManager.instance, secretStore)
|
||||
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 {
|
||||
val secretStore = SecretStore(this@MainActivity)
|
||||
val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
|
||||
return NostrViewModel(NostrManager.instance, secretStore, androidSigner) as T
|
||||
return when {
|
||||
modelClass.isAssignableFrom(AppViewModel::class.java) -> appVM
|
||||
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")
|
||||
} as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val appViewModel: AppViewModel 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?) {
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
throwable.printStackTrace()
|
||||
@@ -69,14 +102,33 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
// Keep the splash screen visible until the signer check is complete
|
||||
splashScreen.setKeepOnScreenCondition {
|
||||
viewModel.signerRequired.value == null
|
||||
accountViewModel.signerRequired.value == null
|
||||
}
|
||||
|
||||
// Bind the lifecycle of the ViewModel to the Activity's lifecycle'
|
||||
viewModel.bindLifecycle(ProcessLifecycleOwner.get().lifecycle)
|
||||
// Bind the lifecycle of the ViewModels
|
||||
val lifecycle = 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 {
|
||||
App(viewModel = viewModel)
|
||||
App(
|
||||
appViewModel = appViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
chatViewModel = chatViewModel,
|
||||
profileViewModel = profileViewModel,
|
||||
relayViewModel = relayViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,8 +65,10 @@ import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.Timestamp
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import su.reya.coop.LocalAccountViewModel
|
||||
import su.reya.coop.LocalChatViewModel
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalProfileViewModel
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.nostr.Room
|
||||
@@ -83,10 +85,12 @@ import su.reya.coop.short
|
||||
fun ChatScreen(id: Long, screening: Boolean = false) {
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val navigator = LocalNavigator.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val accountViewModel = LocalAccountViewModel.current
|
||||
val chatViewModel = LocalChatViewModel.current
|
||||
val profileViewModel = LocalProfileViewModel.current
|
||||
|
||||
// Get chat room by ID
|
||||
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
|
||||
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
|
||||
val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } }
|
||||
|
||||
// Show empty screen
|
||||
@@ -104,12 +108,14 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
||||
return
|
||||
}
|
||||
|
||||
val displayName by remember(room) {
|
||||
room?.nameFlow(viewModel) ?: flowOf("Loading...")
|
||||
val currentUser = accountViewModel.nostr.signer.currentUser
|
||||
|
||||
val displayName by remember(room, currentUser) {
|
||||
room?.nameFlow(profileViewModel, currentUser) ?: flowOf("Loading...")
|
||||
}.collectAsStateWithLifecycle("Loading...")
|
||||
|
||||
val picture by remember(room) {
|
||||
room?.pictureFlow(viewModel) ?: flowOf(null)
|
||||
room?.pictureFlow(profileViewModel) ?: flowOf(null)
|
||||
}.collectAsStateWithLifecycle(null)
|
||||
|
||||
var text by remember { mutableStateOf("") }
|
||||
@@ -129,7 +135,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
||||
loading = true
|
||||
|
||||
// Get messages
|
||||
val initialMessages = viewModel.getChatRoomMessages(id)
|
||||
val initialMessages = chatViewModel.getChatRoomMessages(id)
|
||||
messages.clear()
|
||||
messages.addAll(initialMessages)
|
||||
|
||||
@@ -137,10 +143,10 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
||||
loading = false
|
||||
|
||||
// Get msg relays for each member
|
||||
viewModel.chatRoomConnect(id)
|
||||
chatViewModel.chatRoomConnect(id)
|
||||
|
||||
// Handle new messages
|
||||
viewModel.newEvents.collect { event ->
|
||||
chatViewModel.newEvents.collect { event ->
|
||||
if (event.roomId() == id) {
|
||||
if (event.id() !in messages.map { it.id() }) {
|
||||
messages.add(0, event)
|
||||
@@ -314,7 +320,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
onSend = {
|
||||
viewModel.sendMessage(id, text)
|
||||
chatViewModel.sendMessage(id, text)
|
||||
text = ""
|
||||
}
|
||||
)
|
||||
@@ -331,14 +337,14 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
||||
fun ScreenerCard(room: Room) {
|
||||
val pubkey = room.members.firstOrNull() ?: return
|
||||
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val profileViewModel = LocalProfileViewModel.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var isContact by remember { mutableStateOf(false) }
|
||||
var mutualContacts by remember { mutableStateOf<Set<PublicKey>>(emptySet()) }
|
||||
var lastActivity by remember { mutableStateOf<Timestamp?>(null) }
|
||||
|
||||
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
|
||||
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) }
|
||||
val metadata by metadataFlow.collectAsStateWithLifecycle()
|
||||
|
||||
val profile = metadata?.asRecord()
|
||||
@@ -348,11 +354,11 @@ fun ScreenerCard(room: Room) {
|
||||
LaunchedEffect(pubkey) {
|
||||
scope.launch {
|
||||
// Check contact
|
||||
viewModel.verifyContact(pubkey).let { isContact = it }
|
||||
profileViewModel.verifyContact(pubkey).let { isContact = it }
|
||||
// Get mutual contacts
|
||||
viewModel.mutualContacts(pubkey).let { mutualContacts = it }
|
||||
profileViewModel.mutualContacts(pubkey).let { mutualContacts = it }
|
||||
// Get the last activity
|
||||
viewModel.verifyActivity(pubkey)?.let { lastActivity = it }
|
||||
profileViewModel.verifyActivity(pubkey)?.let { lastActivity = it }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -470,8 +476,9 @@ fun DateSeparator(date: String) {
|
||||
fun ChatMessage(
|
||||
rumor: UnsignedEvent
|
||||
) {
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val currentUser = viewModel.currentUser()
|
||||
val accountViewModel = LocalAccountViewModel.current
|
||||
val chatViewModel = LocalChatViewModel.current
|
||||
val currentUser = accountViewModel.nostr.signer.currentUser
|
||||
val isMine = rumor.author() == currentUser
|
||||
|
||||
val bubbleShape = if (isMine) {
|
||||
@@ -505,7 +512,7 @@ fun ChatMessage(
|
||||
onClick = {
|
||||
val id = rumor.id()
|
||||
if (id != null) {
|
||||
val sent = viewModel.isMessageSent(id)
|
||||
val sent = chatViewModel.isMessageSent(id)
|
||||
println("Sent: $sent")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.Nip05Address
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalProfileViewModel
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.shared.Avatar
|
||||
@@ -66,9 +66,9 @@ import su.reya.coop.short
|
||||
fun ContactListScreen() {
|
||||
val navigator = LocalNavigator.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val profileViewModel = LocalProfileViewModel.current
|
||||
|
||||
val contactList by viewModel.contactList.collectAsStateWithLifecycle()
|
||||
val contactList by profileViewModel.contactList.collectAsStateWithLifecycle()
|
||||
var openAddContactDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
@@ -180,7 +180,7 @@ fun ContactListScreen() {
|
||||
@Composable
|
||||
fun AddContactDialog(onDismissRequest: () -> Unit) {
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val profileViewModel = LocalProfileViewModel.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var contact by remember { mutableStateOf("") }
|
||||
var isError by remember { mutableStateOf(false) }
|
||||
@@ -225,7 +225,7 @@ fun AddContactDialog(onDismissRequest: () -> Unit) {
|
||||
actions = {
|
||||
IconButton(onClick = {
|
||||
scope.launch {
|
||||
val success = viewModel.addContact(contact)
|
||||
val success = profileViewModel.addContact(contact)
|
||||
if (success) onDismissRequest()
|
||||
}
|
||||
}) {
|
||||
@@ -283,8 +283,8 @@ fun ContactListItem(
|
||||
total: Int = 0,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
|
||||
val profileViewModel = LocalProfileViewModel.current
|
||||
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) }
|
||||
val metadata by metadataFlow.collectAsState(initial = null)
|
||||
|
||||
val profile = metadata?.asRecord()
|
||||
@@ -293,7 +293,7 @@ fun ContactListItem(
|
||||
|
||||
SegmentedListItem(
|
||||
onClick = onClick,
|
||||
onLongClick = { viewModel.removeContact(pubkey) },
|
||||
onLongClick = { profileViewModel.removeContact(pubkey) },
|
||||
shapes = ListItemDefaults.segmentedShapes(
|
||||
index = index,
|
||||
count = total
|
||||
|
||||
@@ -91,8 +91,12 @@ import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
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.LocalNostrViewModel
|
||||
import su.reya.coop.LocalProfileViewModel
|
||||
import su.reya.coop.LocalRelayViewModel
|
||||
import su.reya.coop.LocalScanResult
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Screen
|
||||
@@ -113,7 +117,11 @@ fun HomeScreen() {
|
||||
val qrScanResult = LocalScanResult.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val clipboardManager = LocalClipboard.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val appViewModel = LocalAppViewModel.current
|
||||
val accountViewModel = LocalAccountViewModel.current
|
||||
val chatViewModel = LocalChatViewModel.current
|
||||
val profileViewModel = LocalProfileViewModel.current
|
||||
val relayViewModel = LocalRelayViewModel.current
|
||||
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -121,15 +129,15 @@ fun HomeScreen() {
|
||||
val listState = rememberLazyListState()
|
||||
val pullToRefreshState = rememberPullToRefreshState()
|
||||
|
||||
val currentUser = viewModel.currentUser() ?: return
|
||||
val currentUserProfile = viewModel.getMetadata(currentUser)
|
||||
val currentUser = accountViewModel.nostr.signer.currentUser ?: return
|
||||
val currentUserProfile = profileViewModel.getMetadata(currentUser)
|
||||
|
||||
val userProfile by currentUserProfile.collectAsStateWithLifecycle()
|
||||
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
|
||||
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsStateWithLifecycle()
|
||||
val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle()
|
||||
val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
|
||||
val isBannerDismissed by viewModel.isNotificationBannerDismissed.collectAsState()
|
||||
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
|
||||
val isRelayListEmpty by relayViewModel.isRelayListEmpty.collectAsStateWithLifecycle()
|
||||
val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle()
|
||||
val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
|
||||
val isBannerDismissed by appViewModel.isNotificationBannerDismissed.collectAsState()
|
||||
|
||||
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
|
||||
var showBottomSheet by remember { mutableStateOf(false) }
|
||||
@@ -157,7 +165,7 @@ fun HomeScreen() {
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.getChatRooms()
|
||||
chatViewModel.getChatRooms()
|
||||
}
|
||||
|
||||
LaunchedEffect(qrScanResult.content) {
|
||||
@@ -165,7 +173,7 @@ fun HomeScreen() {
|
||||
runCatching { PublicKey.parse(result) }
|
||||
.onSuccess { pubkey ->
|
||||
try {
|
||||
val roomId = viewModel.createChatRoom(listOf(pubkey))
|
||||
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
|
||||
navigator.navigate(Screen.Chat(roomId))
|
||||
} catch (e: Exception) {
|
||||
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||
@@ -281,7 +289,7 @@ fun HomeScreen() {
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TextButton(
|
||||
onClick = { viewModel.dismissNotificationBanner() },
|
||||
onClick = { appViewModel.dismissNotificationBanner() },
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(text = "Maybe later")
|
||||
@@ -322,7 +330,7 @@ fun HomeScreen() {
|
||||
onRefresh = {
|
||||
scope.launch {
|
||||
isRefreshing = true
|
||||
viewModel.refreshChatRooms()
|
||||
chatViewModel.refreshChatRooms()
|
||||
isRefreshing = false
|
||||
}
|
||||
},
|
||||
@@ -392,7 +400,7 @@ fun HomeScreen() {
|
||||
onDismissRequest = { showBottomSheet = false },
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
val pubkey = viewModel.currentUser()
|
||||
val pubkey = accountViewModel.nostr.signer.currentUser
|
||||
val shortPubkey = pubkey?.short() ?: "Not available"
|
||||
|
||||
val userName =
|
||||
@@ -479,7 +487,7 @@ fun HomeScreen() {
|
||||
// Show the relay setup dialog if the msg relay list is empty
|
||||
if (isRelayListEmpty) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { viewModel.dismissRelayWarning() },
|
||||
onDismissRequest = { relayViewModel.dismissRelayWarning() },
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
) {
|
||||
@@ -586,7 +594,7 @@ fun HomeScreen() {
|
||||
scope.launch {
|
||||
isBusy = true
|
||||
try {
|
||||
viewModel.refetchMsgRelays(currentUser)
|
||||
relayViewModel.refetchMsgRelays(currentUser)
|
||||
} catch (e: Exception) {
|
||||
snackbarHostState.showSnackbar("Failed to refresh metadata: ${e.message}")
|
||||
}
|
||||
@@ -606,7 +614,7 @@ fun HomeScreen() {
|
||||
enabled = !isBusy,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
viewModel.useDefaultMsgRelayList()
|
||||
relayViewModel.useDefaultMsgRelayList()
|
||||
sheetState.hide()
|
||||
}
|
||||
},
|
||||
@@ -629,18 +637,18 @@ fun HomeScreen() {
|
||||
@Composable
|
||||
fun NewRequests(requests: List<Room>) {
|
||||
val navigator = LocalNavigator.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val profileViewModel = LocalProfileViewModel.current
|
||||
|
||||
val total = requests.size
|
||||
val firstRoom = requests.getOrNull(0)
|
||||
val secondRoom = requests.getOrNull(1)
|
||||
|
||||
val firstName by remember(firstRoom) {
|
||||
firstRoom?.nameFlow(viewModel) ?: flowOf("")
|
||||
firstRoom?.nameFlow(profileViewModel) ?: flowOf("")
|
||||
}.collectAsStateWithLifecycle("Loading...")
|
||||
|
||||
val secondName by remember(secondRoom) {
|
||||
secondRoom?.nameFlow(viewModel) ?: flowOf("")
|
||||
secondRoom?.nameFlow(profileViewModel) ?: flowOf("")
|
||||
}.collectAsStateWithLifecycle("")
|
||||
|
||||
val supportingText = when {
|
||||
@@ -712,9 +720,9 @@ fun NewRequests(requests: List<Room>) {
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ChatRoom(room: Room, onClick: () -> Unit) {
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val displayName by remember(room) { room.nameFlow(viewModel) }.collectAsStateWithLifecycle("Loading...")
|
||||
val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsStateWithLifecycle(null)
|
||||
val profileViewModel = LocalProfileViewModel.current
|
||||
val displayName by remember(room) { room.nameFlow(profileViewModel) }.collectAsStateWithLifecycle("Loading...")
|
||||
val picture by remember(room) { room.pictureFlow(profileViewModel) }.collectAsStateWithLifecycle(null)
|
||||
|
||||
ListItem(
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
@@ -760,7 +768,7 @@ fun BottomMenuList(
|
||||
onDismiss: (suspend () -> Unit) -> Unit
|
||||
) {
|
||||
val navigator = LocalNavigator.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val accountViewModel = LocalAccountViewModel.current
|
||||
|
||||
val defaultMenuList = listOf(
|
||||
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
|
||||
@@ -790,7 +798,7 @@ fun BottomMenuList(
|
||||
}
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
FilledTonalButton(
|
||||
onClick = { onDismiss { viewModel.logout() } },
|
||||
onClick = { onDismiss { accountViewModel.logout() } },
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError
|
||||
|
||||
@@ -58,8 +58,10 @@ import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.Keys
|
||||
import rust.nostr.sdk.NostrConnectUri
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import su.reya.coop.LocalAccountViewModel
|
||||
import su.reya.coop.LocalAppViewModel
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalProfileViewModel
|
||||
import su.reya.coop.LocalScanResult
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Screen
|
||||
@@ -74,17 +76,19 @@ fun ImportScreen() {
|
||||
val navigator = LocalNavigator.current
|
||||
val qrScanResult = LocalScanResult.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val appViewModel = LocalAppViewModel.current
|
||||
val accountViewModel = LocalAccountViewModel.current
|
||||
val profileViewModel = LocalProfileViewModel.current
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val isBusy by viewModel.isBusy.collectAsStateWithLifecycle(false)
|
||||
val isBusy by appViewModel.isBusy.collectAsStateWithLifecycle(false)
|
||||
var secret by remember { mutableStateOf("") }
|
||||
var pubkey by remember { mutableStateOf<PublicKey?>(null) }
|
||||
|
||||
// Get metadata when pubkey changes
|
||||
val metadata by remember(pubkey) {
|
||||
pubkey?.let(viewModel::getMetadata) ?: flowOf(null)
|
||||
pubkey?.let(profileViewModel::getMetadata) ?: flowOf(null)
|
||||
}.collectAsStateWithLifecycle(null)
|
||||
|
||||
val profile = metadata?.asRecord()
|
||||
@@ -246,10 +250,10 @@ fun ImportScreen() {
|
||||
onClick = {
|
||||
scope.launch {
|
||||
if (pubkey == null) {
|
||||
viewModel.verifyIdentity(secret).let { pubkey = it }
|
||||
accountViewModel.verifyIdentity(secret).let { pubkey = it }
|
||||
} else {
|
||||
// Import the identity
|
||||
viewModel.importIdentity(secret)
|
||||
accountViewModel.importIdentity(secret)
|
||||
// Navigate to the home screen
|
||||
navigator.navigate(Screen.Home)
|
||||
}
|
||||
|
||||
@@ -19,16 +19,16 @@ import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import io.github.alexzhirkevich.qrose.rememberQrCodePainter
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import su.reya.coop.LocalAccountViewModel
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
|
||||
@Composable
|
||||
fun MyQrScreen() {
|
||||
val navigator = LocalNavigator.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val currentUser = viewModel.currentUser() ?: return
|
||||
val accountViewModel = LocalAccountViewModel.current
|
||||
val currentUser = accountViewModel.nostr.signer.currentUser ?: return
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
|
||||
@@ -46,6 +46,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import coop.composeapp.generated.resources.ic_arrow_next
|
||||
@@ -54,8 +55,9 @@ import coop.composeapp.generated.resources.ic_scanner
|
||||
import kotlinx.coroutines.delay
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import su.reya.coop.LocalChatViewModel
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalProfileViewModel
|
||||
import su.reya.coop.LocalScanResult
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Screen
|
||||
@@ -69,13 +71,15 @@ fun NewChatScreen() {
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val navigator = LocalNavigator.current
|
||||
val qrScanResult = LocalScanResult.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val chatViewModel = LocalChatViewModel.current
|
||||
val profileViewModel = LocalProfileViewModel.current
|
||||
|
||||
val contactList by profileViewModel.contactList.collectAsStateWithLifecycle()
|
||||
var query by remember { mutableStateOf("") }
|
||||
|
||||
val contactList by viewModel.contactList.collectAsState(initial = emptySet())
|
||||
val createGroup = remember { mutableStateOf(false) }
|
||||
val searchResults = remember { mutableStateListOf<PublicKey>() }
|
||||
val selectedReceivers = remember { mutableStateListOf<PublicKey>() }
|
||||
var query by remember { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(query) {
|
||||
if (query.length >= 3) {
|
||||
@@ -92,12 +96,12 @@ fun NewChatScreen() {
|
||||
selectedReceivers.add(pubkey)
|
||||
}
|
||||
} else if (query.contains("@")) {
|
||||
val pubkey = viewModel.searchByAddress(query)
|
||||
val pubkey = profileViewModel.searchByAddress(query)
|
||||
if (pubkey != null) {
|
||||
selectedReceivers.add(pubkey)
|
||||
}
|
||||
} else {
|
||||
val results = viewModel.searchByNostr(query)
|
||||
val results = profileViewModel.searchByNostr(query)
|
||||
searchResults.clear()
|
||||
searchResults.addAll(results)
|
||||
}
|
||||
@@ -167,7 +171,7 @@ fun NewChatScreen() {
|
||||
) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = {
|
||||
val roomId = viewModel.createChatRoom(selectedReceivers.toList())
|
||||
val roomId = chatViewModel.createChatRoom(selectedReceivers.toList())
|
||||
navigator.navigate(Screen.Chat(roomId))
|
||||
},
|
||||
expanded = false,
|
||||
@@ -258,7 +262,7 @@ fun NewChatScreen() {
|
||||
items = searchResults,
|
||||
selectedReceivers = selectedReceivers,
|
||||
onContactClick = { pubkey ->
|
||||
val roomId = viewModel.createChatRoom(listOf(pubkey))
|
||||
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
|
||||
navigator.navigate(Screen.Chat(roomId))
|
||||
},
|
||||
)
|
||||
@@ -269,7 +273,7 @@ fun NewChatScreen() {
|
||||
items = contactList.toList(),
|
||||
selectedReceivers = selectedReceivers,
|
||||
onContactClick = { pubkey ->
|
||||
val roomId = viewModel.createChatRoom(listOf(pubkey))
|
||||
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
|
||||
navigator.navigate(Screen.Chat(roomId))
|
||||
}
|
||||
)
|
||||
@@ -286,8 +290,8 @@ fun ReceiverChip(
|
||||
pubkey: PublicKey,
|
||||
onRemove: () -> Unit
|
||||
) {
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
|
||||
val profileViewModel = LocalProfileViewModel.current
|
||||
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) }
|
||||
val metadata by metadataFlow.collectAsState(initial = null)
|
||||
|
||||
val profile = metadata?.asRecord()
|
||||
@@ -372,8 +376,8 @@ fun ContactListItem(
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit
|
||||
) {
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
|
||||
val profileViewModel = LocalProfileViewModel.current
|
||||
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) }
|
||||
val metadata by metadataFlow.collectAsState(initial = null)
|
||||
|
||||
val profile = metadata?.asRecord()
|
||||
|
||||
@@ -5,17 +5,21 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.launch
|
||||
import su.reya.coop.LocalAccountViewModel
|
||||
import su.reya.coop.LocalAppViewModel
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalProfileViewModel
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.shared.ProfileEditor
|
||||
|
||||
@Composable
|
||||
fun NewIdentityScreen() {
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val appViewModel = LocalAppViewModel.current
|
||||
val accountViewModel = LocalAccountViewModel.current
|
||||
val profileViewModel = LocalProfileViewModel.current
|
||||
val navigator = LocalNavigator.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val isBusy by viewModel.isBusy.collectAsStateWithLifecycle(false)
|
||||
val isBusy by appViewModel.isBusy.collectAsStateWithLifecycle(false)
|
||||
|
||||
ProfileEditor(
|
||||
title = "Create a new identity",
|
||||
@@ -24,7 +28,7 @@ fun NewIdentityScreen() {
|
||||
onBack = { navigator.goBack() },
|
||||
onConfirm = { name, bio, bytes, type ->
|
||||
scope.launch {
|
||||
viewModel.createIdentity(name, bio, bytes, type)
|
||||
accountViewModel.createIdentity(name, bio, bytes, type, profileViewModel)
|
||||
navigator.navigate(Screen.Home)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,8 +45,8 @@ import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.coop
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import su.reya.coop.LocalAccountViewModel
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.shared.getExpressiveFontFamily
|
||||
@@ -57,7 +57,7 @@ fun OnboardingScreen() {
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val navigator = LocalNavigator.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val accountViewModel = LocalAccountViewModel.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val logoPainter = painterResource(Res.drawable.coop)
|
||||
@@ -158,9 +158,9 @@ fun OnboardingScreen() {
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
if (viewModel.isExternalSignerAvailable()) {
|
||||
if (accountViewModel.isExternalSignerAvailable()) {
|
||||
try {
|
||||
viewModel.connectExternalSigner()
|
||||
accountViewModel.connectExternalSigner()
|
||||
navigator.navigate(Screen.Home)
|
||||
} catch (e: Exception) {
|
||||
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||
|
||||
@@ -44,8 +44,9 @@ import coop.composeapp.generated.resources.ic_share
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import su.reya.coop.LocalChatViewModel
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalProfileViewModel
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.shared.Avatar
|
||||
@@ -58,12 +59,13 @@ fun ProfileScreen(pubkey: String) {
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val navigator = LocalNavigator.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val chatViewModel = LocalChatViewModel.current
|
||||
val profileViewModel = LocalProfileViewModel.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
|
||||
|
||||
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
|
||||
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) }
|
||||
val metadata by metadataFlow.collectAsStateWithLifecycle()
|
||||
|
||||
val profile = metadata?.asRecord()
|
||||
@@ -159,7 +161,7 @@ fun ProfileScreen(pubkey: String) {
|
||||
onClick = {
|
||||
scope.launch {
|
||||
try {
|
||||
val roomId = viewModel.createChatRoom(listOf(pubkey))
|
||||
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
|
||||
navigator.navigate(Screen.Chat(roomId))
|
||||
} catch (e: Exception) {
|
||||
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||
|
||||
@@ -66,7 +66,7 @@ import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.RelayMetadata
|
||||
import rust.nostr.sdk.RelayUrl
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalRelayViewModel
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@@ -74,7 +74,7 @@ import su.reya.coop.LocalSnackbarHostState
|
||||
fun RelayScreen() {
|
||||
val navigator = LocalNavigator.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val relayViewModel = LocalRelayViewModel.current
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val msgRelayList = remember { mutableStateListOf<RelayUrl>() }
|
||||
@@ -96,8 +96,8 @@ fun RelayScreen() {
|
||||
var relayToDelete by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
relayList.putAll(viewModel.currentUserRelayList())
|
||||
msgRelayList.addAll(viewModel.currentUserMsgRelayList())
|
||||
relayList.putAll(relayViewModel.currentUserRelayList())
|
||||
msgRelayList.addAll(relayViewModel.currentUserMsgRelayList())
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
@@ -321,11 +321,11 @@ fun RelayScreen() {
|
||||
return@launch
|
||||
}
|
||||
try {
|
||||
viewModel.removeMsgRelay(relayToDelete!!)
|
||||
relayViewModel.removeMsgRelay(relayToDelete!!)
|
||||
msgRelayList.removeIf { it.toString() == relayToDelete }
|
||||
relayToDelete = null
|
||||
} catch (e: Exception) {
|
||||
snackbarHostState.showSnackbar("Failed to remove relay")
|
||||
snackbarHostState.showSnackbar("Failed to remove relay: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -349,7 +349,7 @@ fun AddRelayDialog(
|
||||
onMsgRelayAdded: (newRelay: String) -> Unit,
|
||||
onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit,
|
||||
) {
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val relayViewModel = LocalRelayViewModel.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -401,17 +401,17 @@ fun AddRelayDialog(
|
||||
if (!isError) {
|
||||
when (selected) {
|
||||
"Messaging" -> {
|
||||
viewModel.addMsgRelay(relayAddress)
|
||||
relayViewModel.addMsgRelay(relayAddress)
|
||||
onMsgRelayAdded(relayAddress)
|
||||
}
|
||||
|
||||
"Inbox" -> {
|
||||
viewModel.addInboxRelay(relayAddress)
|
||||
relayViewModel.addInboxRelay(relayAddress)
|
||||
onRelayAdded(relayAddress, RelayMetadata.WRITE)
|
||||
}
|
||||
|
||||
"Outbox" -> {
|
||||
viewModel.addOutboxRelay(relayAddress)
|
||||
relayViewModel.addOutboxRelay(relayAddress)
|
||||
onRelayAdded(relayAddress, RelayMetadata.READ)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,8 +37,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import kotlinx.coroutines.launch
|
||||
import su.reya.coop.LocalChatViewModel
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.nostr.RoomKind
|
||||
@@ -48,14 +48,14 @@ import su.reya.coop.nostr.RoomKind
|
||||
fun RequestListScreen() {
|
||||
val navigator = LocalNavigator.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val chatViewModel = LocalChatViewModel.current
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
val pullToRefreshState = rememberPullToRefreshState()
|
||||
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
|
||||
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
|
||||
|
||||
// Get all request rooms
|
||||
val requests = remember(chatRooms) {
|
||||
@@ -103,7 +103,7 @@ fun RequestListScreen() {
|
||||
onRefresh = {
|
||||
scope.launch {
|
||||
isRefreshing = true
|
||||
viewModel.refreshChatRooms()
|
||||
chatViewModel.refreshChatRooms()
|
||||
isRefreshing = false
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
package su.reya.coop.screens
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.launch
|
||||
import su.reya.coop.LocalAccountViewModel
|
||||
import su.reya.coop.LocalAppViewModel
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalProfileViewModel
|
||||
import su.reya.coop.shared.ProfileEditor
|
||||
|
||||
@Composable
|
||||
fun UpdateProfileScreen() {
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val appViewModel = LocalAppViewModel.current
|
||||
val accountViewModel = LocalAccountViewModel.current
|
||||
val profileViewModel = LocalProfileViewModel.current
|
||||
val navigator = LocalNavigator.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val currentUser = viewModel.currentUser() ?: return
|
||||
val metadata by viewModel.getMetadata(currentUser).collectAsState(initial = null)
|
||||
val isBusy by viewModel.isBusy.collectAsStateWithLifecycle(false)
|
||||
val currentUser = accountViewModel.nostr.signer.currentUser ?: return
|
||||
val metadata by profileViewModel.getMetadata(currentUser).collectAsStateWithLifecycle()
|
||||
val isBusy by appViewModel.isBusy.collectAsStateWithLifecycle(false)
|
||||
|
||||
val profile = metadata?.asRecord()
|
||||
|
||||
@@ -32,7 +35,7 @@ fun UpdateProfileScreen() {
|
||||
onBack = { navigator.goBack() },
|
||||
onConfirm = { name, bio, bytes, type ->
|
||||
scope.launch {
|
||||
viewModel.updateProfile(name, bio, bytes, type)
|
||||
profileViewModel.updateProfile(name, bio, bytes, type)
|
||||
navigator.goBack()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,18 +4,22 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import su.reya.coop.NostrViewModel
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import su.reya.coop.nostr.Room
|
||||
import su.reya.coop.short
|
||||
import su.reya.coop.viewmodels.ProfileViewModel
|
||||
|
||||
fun Room.nameFlow(viewModel: NostrViewModel): Flow<String> {
|
||||
fun Room.nameFlow(
|
||||
profileViewModel: ProfileViewModel,
|
||||
currentUser: PublicKey? = null
|
||||
): Flow<String> {
|
||||
// Return early if there's a custom subject/room name
|
||||
subject?.takeIf { it.isNotBlank() }?.let { return flowOf(it) }
|
||||
|
||||
val displayMembers = if (isGroup()) members.take(2) else members.take(1)
|
||||
if (displayMembers.isEmpty()) return flowOf("Unknown")
|
||||
|
||||
return combine(displayMembers.map { viewModel.getMetadata(it) }) { metadataArray ->
|
||||
return combine(displayMembers.map { profileViewModel.getMetadata(it) }) { metadataArray ->
|
||||
val names = metadataArray.mapIndexed { i, metadata ->
|
||||
val profile = metadata?.asRecord()
|
||||
profile?.displayName?.takeIf { it.isNotBlank() }
|
||||
@@ -29,12 +33,12 @@ fun Room.nameFlow(viewModel: NostrViewModel): Flow<String> {
|
||||
if (extraCount > 0) "$combined, +$extraCount" else combined
|
||||
} else {
|
||||
val name = names.first()
|
||||
if (displayMembers.first() == viewModel.currentUser()) "$name (you)" else name
|
||||
if (displayMembers.first() == currentUser) "$name (you)" else name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Room.pictureFlow(viewModel: NostrViewModel): Flow<String?> {
|
||||
fun Room.pictureFlow(profileViewModel: ProfileViewModel): Flow<String?> {
|
||||
val firstMember = members.firstOrNull() ?: return flowOf(null)
|
||||
return viewModel.getMetadata(firstMember).map { it?.asRecord()?.picture }
|
||||
}
|
||||
return profileViewModel.getMetadata(firstMember).map { it?.asRecord()?.picture }
|
||||
}
|
||||
|
||||
8
shared/src/commonMain/kotlin/su/reya/coop/Extensions.kt
Normal file
8
shared/src/commonMain/kotlin/su/reya/coop/Extensions.kt
Normal file
@@ -0,0 +1,8 @@
|
||||
package su.reya.coop
|
||||
|
||||
import rust.nostr.sdk.PublicKey
|
||||
|
||||
fun PublicKey.short(): String {
|
||||
val bech32 = toBech32()
|
||||
return bech32.substring(0, 6) + "..." + bech32.substring(bech32.length - 4)
|
||||
}
|
||||
@@ -1,905 +0,0 @@
|
||||
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(
|
||||
private 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")
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
private suspend fun blossomUpload(file: ByteArray, contentType: String): 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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun PublicKey.short(): String {
|
||||
val bech32 = toBech32()
|
||||
return bech32.substring(0, 6) + "..." + bech32.substring(bech32.length - 4)
|
||||
}
|
||||
@@ -63,4 +63,4 @@ class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner {
|
||||
override suspend fun nip44DecryptAsync(publicKey: PublicKey, payload: String): String {
|
||||
return get().nip44DecryptAsync(publicKey, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
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",
|
||||
profileViewModel: ProfileViewModel
|
||||
) {
|
||||
appViewModel.setBusy(true)
|
||||
|
||||
val keys = Keys.generate()
|
||||
val secret = keys.secretKey().toBech32()
|
||||
|
||||
try {
|
||||
val avatarUrl = picture?.let { profileViewModel.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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package su.reya.coop.viewmodels
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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 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
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
viewModelScope.launch {
|
||||
withContext(NonCancellable) {
|
||||
nostr.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
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.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 kotlinx.serialization.json.Json
|
||||
import rust.nostr.sdk.Metadata
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.Timestamp
|
||||
import su.reya.coop.blossom.BlossomClient
|
||||
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 blossomUpload(file: ByteArray, contentType: String?): String? {
|
||||
try {
|
||||
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) {
|
||||
appViewModel.showError("Error: ${e.message}")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateProfile(
|
||||
name: String? = null,
|
||||
bio: String? = null,
|
||||
picture: ByteArray? = null,
|
||||
contentType: String? = null
|
||||
) {
|
||||
appViewModel.setBusy(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")
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package su.reya.coop.viewmodels
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.RelayMetadata
|
||||
import rust.nostr.sdk.RelayUrl
|
||||
import su.reya.coop.nostr.Nostr
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class RelayViewModel(
|
||||
private val nostr: Nostr,
|
||||
private val appViewModel: AppViewModel,
|
||||
private val chatViewModel: ChatViewModel,
|
||||
private val profileViewModel: ProfileViewModel
|
||||
) : ViewModel() {
|
||||
private val _isRelayListEmpty = MutableStateFlow(false)
|
||||
val isRelayListEmpty = _isRelayListEmpty.asStateFlow()
|
||||
|
||||
fun reconnect() {
|
||||
viewModelScope.launch {
|
||||
nostr.waitUntilInitialized()
|
||||
nostr.reconnect()
|
||||
}
|
||||
}
|
||||
|
||||
fun observeSignerAndCheckRelays() {
|
||||
viewModelScope.launch {
|
||||
while (true) {
|
||||
val pubkey = nostr.signer.currentUser
|
||||
if (pubkey != null) {
|
||||
val rooms = nostr.messages.getChatRooms() ?: emptySet()
|
||||
if (rooms.isNotEmpty()) {
|
||||
chatViewModel.mergeChatRooms(rooms)
|
||||
// Note: isPartialProcessedGiftWrap is in ChatViewModel
|
||||
// We might need to expose a way to set it or just let it be updated by observers
|
||||
}
|
||||
|
||||
profileViewModel.getUserMetadata()
|
||||
|
||||
delay(2.seconds)
|
||||
|
||||
val relays = nostr.relays.getMsgRelays(pubkey)
|
||||
if (relays.isEmpty()) _isRelayListEmpty.value = true
|
||||
break
|
||||
}
|
||||
delay(500.milliseconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun refetchMsgRelays(pubkey: PublicKey) {
|
||||
val relays = nostr.relays.fetchMsgRelays(pubkey)
|
||||
if (relays.isNotEmpty()) dismissRelayWarning()
|
||||
}
|
||||
|
||||
fun dismissRelayWarning() {
|
||||
_isRelayListEmpty.value = false
|
||||
}
|
||||
|
||||
suspend fun useDefaultMsgRelayList() {
|
||||
try {
|
||||
val defaultRelays = nostr.relays.getDefaultMsgRelayList()
|
||||
nostr.relays.setMsgRelays(defaultRelays)
|
||||
} catch (e: Exception) {
|
||||
appViewModel.showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun currentUserRelayList(): Map<RelayUrl, RelayMetadata?> {
|
||||
try {
|
||||
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
||||
return nostr.relays.getRelayList(currentUser)
|
||||
} catch (e: Exception) {
|
||||
appViewModel.showError("Error: ${e.message}")
|
||||
return emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addInboxRelay(relay: String) {
|
||||
try {
|
||||
val relayUrl = RelayUrl.parse(relay)
|
||||
val relays = currentUserRelayList().toMutableMap()
|
||||
relays[relayUrl] = RelayMetadata.WRITE
|
||||
nostr.relays.setRelaylist(relays)
|
||||
} catch (e: Exception) {
|
||||
appViewModel.showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addOutboxRelay(relay: String) {
|
||||
try {
|
||||
val relayUrl = RelayUrl.parse(relay)
|
||||
val relays = currentUserRelayList().toMutableMap()
|
||||
relays[relayUrl] = RelayMetadata.READ
|
||||
nostr.relays.setRelaylist(relays)
|
||||
} catch (e: Exception) {
|
||||
appViewModel.showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeRelay(relay: String) {
|
||||
try {
|
||||
val relayUrl = RelayUrl.parse(relay)
|
||||
val relays = currentUserRelayList().toMutableMap()
|
||||
relays.remove(relayUrl)
|
||||
nostr.relays.setRelaylist(relays)
|
||||
} catch (e: Exception) {
|
||||
appViewModel.showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun currentUserMsgRelayList(): List<RelayUrl> {
|
||||
try {
|
||||
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
||||
return nostr.relays.getMsgRelays(currentUser)
|
||||
} catch (e: Exception) {
|
||||
appViewModel.showError("Error: ${e.message}")
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addMsgRelay(relay: String) {
|
||||
try {
|
||||
val relayUrl = RelayUrl.parse(relay)
|
||||
val relays = currentUserMsgRelayList().toMutableSet()
|
||||
relays.add(relayUrl)
|
||||
nostr.relays.setMsgRelays(relays.toList())
|
||||
} catch (e: Exception) {
|
||||
appViewModel.showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeMsgRelay(relay: String) {
|
||||
try {
|
||||
val relayUrl = RelayUrl.parse(relay)
|
||||
val relays = currentUserMsgRelayList().toMutableSet()
|
||||
relays.remove(relayUrl)
|
||||
nostr.relays.setMsgRelays(relays.toList())
|
||||
} catch (e: Exception) {
|
||||
appViewModel.showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user