chore: re-structure project #31
@@ -6,6 +6,8 @@ import android.content.Intent
|
|||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
|
import su.reya.coop.nostr.ExternalSignerHandler
|
||||||
|
import su.reya.coop.nostr.ExternalSignerResult
|
||||||
|
|
||||||
class AndroidExternalSigner(
|
class AndroidExternalSigner(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
|
|||||||
@@ -47,9 +47,30 @@ import su.reya.coop.screens.RelayScreen
|
|||||||
import su.reya.coop.screens.RequestListScreen
|
import su.reya.coop.screens.RequestListScreen
|
||||||
import su.reya.coop.screens.ScanScreen
|
import su.reya.coop.screens.ScanScreen
|
||||||
import su.reya.coop.screens.UpdateProfileScreen
|
import su.reya.coop.screens.UpdateProfileScreen
|
||||||
|
import su.reya.coop.viewmodels.AccountViewModel
|
||||||
|
import su.reya.coop.viewmodels.AppViewModel
|
||||||
|
import su.reya.coop.viewmodels.ChatViewModel
|
||||||
|
import su.reya.coop.viewmodels.ProfileViewModel
|
||||||
|
import su.reya.coop.viewmodels.RelayViewModel
|
||||||
|
|
||||||
val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
|
val LocalAppViewModel = staticCompositionLocalOf<AppViewModel> {
|
||||||
error("No NostrViewModel provided")
|
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> {
|
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
|
||||||
@@ -66,14 +87,21 @@ val LocalScanResult = staticCompositionLocalOf<QrScanResult> {
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun App(viewModel: NostrViewModel) {
|
fun App(
|
||||||
|
appViewModel: AppViewModel,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
chatViewModel: ChatViewModel,
|
||||||
|
profileViewModel: ProfileViewModel,
|
||||||
|
relayViewModel: RelayViewModel,
|
||||||
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val activity = context as? ComponentActivity
|
val activity = context as? ComponentActivity
|
||||||
val backStack = rememberNavBackStack(Screen.Home)
|
val backStack = rememberNavBackStack(Screen.Home)
|
||||||
val navigator = remember(backStack) { Navigator(backStack) }
|
val navigator = remember(backStack) { Navigator(backStack) }
|
||||||
val qrScanResult = remember { QrScanResult() }
|
val qrScanResult = remember { QrScanResult() }
|
||||||
|
|
||||||
val signerRequired by viewModel.signerRequired.collectAsStateWithLifecycle()
|
// Get the signer required state
|
||||||
|
val signerRequired by accountViewModel.signerRequired.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// Snackbar
|
// Snackbar
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
@@ -100,7 +128,7 @@ fun App(viewModel: NostrViewModel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.errorEvents.collect { message ->
|
appViewModel.errorEvents.collect { message ->
|
||||||
snackbarHostState.showSnackbar(message)
|
snackbarHostState.showSnackbar(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,7 +171,11 @@ fun App(viewModel: NostrViewModel) {
|
|||||||
motionScheme = MotionScheme.expressive(),
|
motionScheme = MotionScheme.expressive(),
|
||||||
) {
|
) {
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalNostrViewModel provides viewModel,
|
LocalAppViewModel provides appViewModel,
|
||||||
|
LocalAccountViewModel provides accountViewModel,
|
||||||
|
LocalChatViewModel provides chatViewModel,
|
||||||
|
LocalProfileViewModel provides profileViewModel,
|
||||||
|
LocalRelayViewModel provides relayViewModel,
|
||||||
LocalSnackbarHostState provides snackbarHostState,
|
LocalSnackbarHostState provides snackbarHostState,
|
||||||
LocalNavigator provides navigator,
|
LocalNavigator provides navigator,
|
||||||
LocalScanResult provides qrScanResult,
|
LocalScanResult provides qrScanResult,
|
||||||
|
|||||||
@@ -10,10 +10,23 @@ import androidx.activity.enableEdgeToEdge
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
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.ProcessLifecycleOwner
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
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.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
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@@ -21,16 +34,37 @@ class MainActivity : ComponentActivity() {
|
|||||||
val externalSignerLauncher = ExternalSignerLauncher()
|
val externalSignerLauncher = ExternalSignerLauncher()
|
||||||
}
|
}
|
||||||
|
|
||||||
private val viewModel: NostrViewModel by viewModels {
|
private val factory by lazy {
|
||||||
object : ViewModelProvider.Factory {
|
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 {
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
val secretStore = SecretStore(this@MainActivity)
|
return when {
|
||||||
val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
|
modelClass.isAssignableFrom(AppViewModel::class.java) -> appVM
|
||||||
return NostrViewModel(NostrManager.instance, secretStore, androidSigner) as T
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||||
throwable.printStackTrace()
|
throwable.printStackTrace()
|
||||||
@@ -68,14 +102,33 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
// Keep the splash screen visible until the signer check is complete
|
// Keep the splash screen visible until the signer check is complete
|
||||||
splashScreen.setKeepOnScreenCondition {
|
splashScreen.setKeepOnScreenCondition {
|
||||||
viewModel.signerRequired.value == null
|
accountViewModel.signerRequired.value == null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bind the lifecycle of the ViewModel to the Activity's lifecycle'
|
// Bind the lifecycle of the ViewModels
|
||||||
viewModel.bindLifecycle(ProcessLifecycleOwner.get().lifecycle)
|
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 {
|
setContent {
|
||||||
App(viewModel = viewModel)
|
App(
|
||||||
|
appViewModel = appViewModel,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
chatViewModel = chatViewModel,
|
||||||
|
profileViewModel = profileViewModel,
|
||||||
|
relayViewModel = relayViewModel,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import kotlinx.coroutines.Job
|
|||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import su.reya.coop.nostr.NostrManager
|
||||||
|
import su.reya.coop.nostr.roomId
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
private const val GROUP_KEY_MESSAGES = "su.reya.coop.MESSAGES"
|
private const val GROUP_KEY_MESSAGES = "su.reya.coop.MESSAGES"
|
||||||
@@ -65,17 +67,17 @@ class NostrForegroundService : Service() {
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw IllegalStateException("Failed to initialize Nostr Client", e)
|
throw IllegalStateException("Failed to initialize Nostr Client", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect to bootstrap relays
|
// Connect to bootstrap relays
|
||||||
nostr.connectBootstrapRelays()
|
nostr.connectBootstrapRelays()
|
||||||
|
|
||||||
// Handle notifications
|
// Handle notifications
|
||||||
nostr.handleNotifications(
|
nostr.handleNotifications(
|
||||||
onMetadataUpdate = { pubkey, metadata ->
|
onMetadataUpdate = { pubkey, metadata ->
|
||||||
serviceScope.launch { nostr.emitMetadataUpdate(pubkey, metadata) }
|
serviceScope.launch { nostr.profiles.emitMetadataUpdate(pubkey, metadata) }
|
||||||
},
|
},
|
||||||
onContactListUpdate = { contacts ->
|
onContactListUpdate = { contacts ->
|
||||||
serviceScope.launch { nostr.emitContactListUpdate(contacts) }
|
serviceScope.launch { nostr.profiles.emitContactListUpdate(contacts) }
|
||||||
},
|
},
|
||||||
onNewMessage = { event ->
|
onNewMessage = { event ->
|
||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
|
|||||||
@@ -65,14 +65,16 @@ import org.jetbrains.compose.resources.painterResource
|
|||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import rust.nostr.sdk.Timestamp
|
import rust.nostr.sdk.Timestamp
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
|
import su.reya.coop.LocalAccountViewModel
|
||||||
|
import su.reya.coop.LocalChatViewModel
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalProfileViewModel
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Room
|
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.formatAsGroupHeader
|
import su.reya.coop.nostr.Room
|
||||||
import su.reya.coop.humanReadable
|
import su.reya.coop.nostr.formatAsGroupHeader
|
||||||
import su.reya.coop.roomId
|
import su.reya.coop.nostr.humanReadable
|
||||||
|
import su.reya.coop.nostr.roomId
|
||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
import su.reya.coop.shared.getExpressiveFontFamily
|
import su.reya.coop.shared.getExpressiveFontFamily
|
||||||
import su.reya.coop.shared.nameFlow
|
import su.reya.coop.shared.nameFlow
|
||||||
@@ -83,10 +85,12 @@ import su.reya.coop.short
|
|||||||
fun ChatScreen(id: Long, screening: Boolean = false) {
|
fun ChatScreen(id: Long, screening: Boolean = false) {
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val navigator = LocalNavigator.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
|
// 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 } } }
|
val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } }
|
||||||
|
|
||||||
// Show empty screen
|
// Show empty screen
|
||||||
@@ -104,12 +108,14 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val displayName by remember(room) {
|
val currentUser = accountViewModel.nostr.signer.currentUser
|
||||||
room?.nameFlow(viewModel) ?: flowOf("Loading...")
|
|
||||||
|
val displayName by remember(room, currentUser) {
|
||||||
|
room?.nameFlow(profileViewModel, currentUser) ?: flowOf("Loading...")
|
||||||
}.collectAsStateWithLifecycle("Loading...")
|
}.collectAsStateWithLifecycle("Loading...")
|
||||||
|
|
||||||
val picture by remember(room) {
|
val picture by remember(room) {
|
||||||
room?.pictureFlow(viewModel) ?: flowOf(null)
|
room?.pictureFlow(profileViewModel) ?: flowOf(null)
|
||||||
}.collectAsStateWithLifecycle(null)
|
}.collectAsStateWithLifecycle(null)
|
||||||
|
|
||||||
var text by remember { mutableStateOf("") }
|
var text by remember { mutableStateOf("") }
|
||||||
@@ -129,7 +135,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
|||||||
loading = true
|
loading = true
|
||||||
|
|
||||||
// Get messages
|
// Get messages
|
||||||
val initialMessages = viewModel.getChatRoomMessages(id)
|
val initialMessages = chatViewModel.getChatRoomMessages(id)
|
||||||
messages.clear()
|
messages.clear()
|
||||||
messages.addAll(initialMessages)
|
messages.addAll(initialMessages)
|
||||||
|
|
||||||
@@ -137,10 +143,10 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
|||||||
loading = false
|
loading = false
|
||||||
|
|
||||||
// Get msg relays for each member
|
// Get msg relays for each member
|
||||||
viewModel.chatRoomConnect(id)
|
chatViewModel.chatRoomConnect(id)
|
||||||
|
|
||||||
// Handle new messages
|
// Handle new messages
|
||||||
viewModel.newEvents.collect { event ->
|
chatViewModel.newEvents.collect { event ->
|
||||||
if (event.roomId() == id) {
|
if (event.roomId() == id) {
|
||||||
if (event.id() !in messages.map { it.id() }) {
|
if (event.id() !in messages.map { it.id() }) {
|
||||||
messages.add(0, event)
|
messages.add(0, event)
|
||||||
@@ -314,7 +320,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
|||||||
value = text,
|
value = text,
|
||||||
onValueChange = { text = it },
|
onValueChange = { text = it },
|
||||||
onSend = {
|
onSend = {
|
||||||
viewModel.sendMessage(id, text)
|
chatViewModel.sendMessage(id, text)
|
||||||
text = ""
|
text = ""
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -331,14 +337,14 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
|||||||
fun ScreenerCard(room: Room) {
|
fun ScreenerCard(room: Room) {
|
||||||
val pubkey = room.members.firstOrNull() ?: return
|
val pubkey = room.members.firstOrNull() ?: return
|
||||||
|
|
||||||
val viewModel = LocalNostrViewModel.current
|
val profileViewModel = LocalProfileViewModel.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
var isContact by remember { mutableStateOf(false) }
|
var isContact by remember { mutableStateOf(false) }
|
||||||
var mutualContacts by remember { mutableStateOf<Set<PublicKey>>(emptySet()) }
|
var mutualContacts by remember { mutableStateOf<Set<PublicKey>>(emptySet()) }
|
||||||
var lastActivity by remember { mutableStateOf<Timestamp?>(null) }
|
var lastActivity by remember { mutableStateOf<Timestamp?>(null) }
|
||||||
|
|
||||||
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
|
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) }
|
||||||
val metadata by metadataFlow.collectAsStateWithLifecycle()
|
val metadata by metadataFlow.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
val profile = metadata?.asRecord()
|
||||||
@@ -348,11 +354,11 @@ fun ScreenerCard(room: Room) {
|
|||||||
LaunchedEffect(pubkey) {
|
LaunchedEffect(pubkey) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
// Check contact
|
// Check contact
|
||||||
viewModel.verifyContact(pubkey).let { isContact = it }
|
profileViewModel.verifyContact(pubkey).let { isContact = it }
|
||||||
// Get mutual contacts
|
// Get mutual contacts
|
||||||
viewModel.mutualContacts(pubkey).let { mutualContacts = it }
|
profileViewModel.mutualContacts(pubkey).let { mutualContacts = it }
|
||||||
// Get the last activity
|
// 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(
|
fun ChatMessage(
|
||||||
rumor: UnsignedEvent
|
rumor: UnsignedEvent
|
||||||
) {
|
) {
|
||||||
val viewModel = LocalNostrViewModel.current
|
val accountViewModel = LocalAccountViewModel.current
|
||||||
val currentUser = viewModel.currentUser()
|
val chatViewModel = LocalChatViewModel.current
|
||||||
|
val currentUser = accountViewModel.nostr.signer.currentUser
|
||||||
val isMine = rumor.author() == currentUser
|
val isMine = rumor.author() == currentUser
|
||||||
|
|
||||||
val bubbleShape = if (isMine) {
|
val bubbleShape = if (isMine) {
|
||||||
@@ -505,7 +512,7 @@ fun ChatMessage(
|
|||||||
onClick = {
|
onClick = {
|
||||||
val id = rumor.id()
|
val id = rumor.id()
|
||||||
if (id != null) {
|
if (id != null) {
|
||||||
val sent = viewModel.isMessageSent(id)
|
val sent = chatViewModel.isMessageSent(id)
|
||||||
println("Sent: $sent")
|
println("Sent: $sent")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ import org.jetbrains.compose.resources.painterResource
|
|||||||
import rust.nostr.sdk.Nip05Address
|
import rust.nostr.sdk.Nip05Address
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalProfileViewModel
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
@@ -66,9 +66,9 @@ import su.reya.coop.short
|
|||||||
fun ContactListScreen() {
|
fun ContactListScreen() {
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val 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) }
|
var openAddContactDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -180,7 +180,7 @@ fun ContactListScreen() {
|
|||||||
@Composable
|
@Composable
|
||||||
fun AddContactDialog(onDismissRequest: () -> Unit) {
|
fun AddContactDialog(onDismissRequest: () -> Unit) {
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val profileViewModel = LocalProfileViewModel.current
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
var contact by remember { mutableStateOf("") }
|
var contact by remember { mutableStateOf("") }
|
||||||
var isError by remember { mutableStateOf(false) }
|
var isError by remember { mutableStateOf(false) }
|
||||||
@@ -225,7 +225,7 @@ fun AddContactDialog(onDismissRequest: () -> Unit) {
|
|||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val success = viewModel.addContact(contact)
|
val success = profileViewModel.addContact(contact)
|
||||||
if (success) onDismissRequest()
|
if (success) onDismissRequest()
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
@@ -283,8 +283,8 @@ fun ContactListItem(
|
|||||||
total: Int = 0,
|
total: Int = 0,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val viewModel = LocalNostrViewModel.current
|
val profileViewModel = LocalProfileViewModel.current
|
||||||
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
|
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) }
|
||||||
val metadata by metadataFlow.collectAsState(initial = null)
|
val metadata by metadataFlow.collectAsState(initial = null)
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
val profile = metadata?.asRecord()
|
||||||
@@ -293,7 +293,7 @@ fun ContactListItem(
|
|||||||
|
|
||||||
SegmentedListItem(
|
SegmentedListItem(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = { viewModel.removeContact(pubkey) },
|
onLongClick = { profileViewModel.removeContact(pubkey) },
|
||||||
shapes = ListItemDefaults.segmentedShapes(
|
shapes = ListItemDefaults.segmentedShapes(
|
||||||
index = index,
|
index = index,
|
||||||
count = total
|
count = total
|
||||||
|
|||||||
@@ -91,14 +91,18 @@ import kotlinx.coroutines.flow.flowOf
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import su.reya.coop.LocalAccountViewModel
|
||||||
|
import su.reya.coop.LocalAppViewModel
|
||||||
|
import su.reya.coop.LocalChatViewModel
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalProfileViewModel
|
||||||
|
import su.reya.coop.LocalRelayViewModel
|
||||||
import su.reya.coop.LocalScanResult
|
import su.reya.coop.LocalScanResult
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Room
|
|
||||||
import su.reya.coop.RoomKind
|
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.ago
|
import su.reya.coop.nostr.Room
|
||||||
|
import su.reya.coop.nostr.RoomKind
|
||||||
|
import su.reya.coop.nostr.ago
|
||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
import su.reya.coop.shared.getExpressiveFontFamily
|
import su.reya.coop.shared.getExpressiveFontFamily
|
||||||
import su.reya.coop.shared.nameFlow
|
import su.reya.coop.shared.nameFlow
|
||||||
@@ -113,7 +117,11 @@ fun HomeScreen() {
|
|||||||
val qrScanResult = LocalScanResult.current
|
val qrScanResult = LocalScanResult.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val clipboardManager = LocalClipboard.current
|
val clipboardManager = LocalClipboard.current
|
||||||
val 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()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -121,15 +129,15 @@ fun HomeScreen() {
|
|||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val pullToRefreshState = rememberPullToRefreshState()
|
val pullToRefreshState = rememberPullToRefreshState()
|
||||||
|
|
||||||
val currentUser = viewModel.currentUser() ?: return
|
val currentUser = accountViewModel.nostr.signer.currentUser ?: return
|
||||||
val currentUserProfile = viewModel.getMetadata(currentUser)
|
val currentUserProfile = profileViewModel.getMetadata(currentUser)
|
||||||
|
|
||||||
val userProfile by currentUserProfile.collectAsStateWithLifecycle()
|
val userProfile by currentUserProfile.collectAsStateWithLifecycle()
|
||||||
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
|
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
|
||||||
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsStateWithLifecycle()
|
val isRelayListEmpty by relayViewModel.isRelayListEmpty.collectAsStateWithLifecycle()
|
||||||
val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle()
|
val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle()
|
||||||
val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
|
val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
|
||||||
val isBannerDismissed by viewModel.isNotificationBannerDismissed.collectAsState()
|
val isBannerDismissed by appViewModel.isNotificationBannerDismissed.collectAsState()
|
||||||
|
|
||||||
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
|
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
|
||||||
var showBottomSheet by remember { mutableStateOf(false) }
|
var showBottomSheet by remember { mutableStateOf(false) }
|
||||||
@@ -157,7 +165,7 @@ fun HomeScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.getChatRooms()
|
chatViewModel.getChatRooms()
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(qrScanResult.content) {
|
LaunchedEffect(qrScanResult.content) {
|
||||||
@@ -165,7 +173,7 @@ fun HomeScreen() {
|
|||||||
runCatching { PublicKey.parse(result) }
|
runCatching { PublicKey.parse(result) }
|
||||||
.onSuccess { pubkey ->
|
.onSuccess { pubkey ->
|
||||||
try {
|
try {
|
||||||
val roomId = viewModel.createChatRoom(listOf(pubkey))
|
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
|
||||||
navigator.navigate(Screen.Chat(roomId))
|
navigator.navigate(Screen.Chat(roomId))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.message?.let { snackbarHostState.showSnackbar(it) }
|
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||||
@@ -281,7 +289,7 @@ fun HomeScreen() {
|
|||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = { viewModel.dismissNotificationBanner() },
|
onClick = { appViewModel.dismissNotificationBanner() },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
) {
|
) {
|
||||||
Text(text = "Maybe later")
|
Text(text = "Maybe later")
|
||||||
@@ -322,7 +330,7 @@ fun HomeScreen() {
|
|||||||
onRefresh = {
|
onRefresh = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
isRefreshing = true
|
isRefreshing = true
|
||||||
viewModel.refreshChatRooms()
|
chatViewModel.refreshChatRooms()
|
||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -392,7 +400,7 @@ fun HomeScreen() {
|
|||||||
onDismissRequest = { showBottomSheet = false },
|
onDismissRequest = { showBottomSheet = false },
|
||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
) {
|
) {
|
||||||
val pubkey = viewModel.currentUser()
|
val pubkey = accountViewModel.nostr.signer.currentUser
|
||||||
val shortPubkey = pubkey?.short() ?: "Not available"
|
val shortPubkey = pubkey?.short() ?: "Not available"
|
||||||
|
|
||||||
val userName =
|
val userName =
|
||||||
@@ -479,7 +487,7 @@ fun HomeScreen() {
|
|||||||
// Show the relay setup dialog if the msg relay list is empty
|
// Show the relay setup dialog if the msg relay list is empty
|
||||||
if (isRelayListEmpty) {
|
if (isRelayListEmpty) {
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = { viewModel.dismissRelayWarning() },
|
onDismissRequest = { relayViewModel.dismissRelayWarning() },
|
||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
) {
|
) {
|
||||||
@@ -586,7 +594,7 @@ fun HomeScreen() {
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
isBusy = true
|
isBusy = true
|
||||||
try {
|
try {
|
||||||
viewModel.refetchMsgRelays(currentUser)
|
relayViewModel.refetchMsgRelays(currentUser)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
snackbarHostState.showSnackbar("Failed to refresh metadata: ${e.message}")
|
snackbarHostState.showSnackbar("Failed to refresh metadata: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -606,7 +614,7 @@ fun HomeScreen() {
|
|||||||
enabled = !isBusy,
|
enabled = !isBusy,
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
viewModel.useDefaultMsgRelayList()
|
relayViewModel.useDefaultMsgRelayList()
|
||||||
sheetState.hide()
|
sheetState.hide()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -629,18 +637,18 @@ fun HomeScreen() {
|
|||||||
@Composable
|
@Composable
|
||||||
fun NewRequests(requests: List<Room>) {
|
fun NewRequests(requests: List<Room>) {
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val profileViewModel = LocalProfileViewModel.current
|
||||||
|
|
||||||
val total = requests.size
|
val total = requests.size
|
||||||
val firstRoom = requests.getOrNull(0)
|
val firstRoom = requests.getOrNull(0)
|
||||||
val secondRoom = requests.getOrNull(1)
|
val secondRoom = requests.getOrNull(1)
|
||||||
|
|
||||||
val firstName by remember(firstRoom) {
|
val firstName by remember(firstRoom) {
|
||||||
firstRoom?.nameFlow(viewModel) ?: flowOf("")
|
firstRoom?.nameFlow(profileViewModel) ?: flowOf("")
|
||||||
}.collectAsStateWithLifecycle("Loading...")
|
}.collectAsStateWithLifecycle("Loading...")
|
||||||
|
|
||||||
val secondName by remember(secondRoom) {
|
val secondName by remember(secondRoom) {
|
||||||
secondRoom?.nameFlow(viewModel) ?: flowOf("")
|
secondRoom?.nameFlow(profileViewModel) ?: flowOf("")
|
||||||
}.collectAsStateWithLifecycle("")
|
}.collectAsStateWithLifecycle("")
|
||||||
|
|
||||||
val supportingText = when {
|
val supportingText = when {
|
||||||
@@ -712,9 +720,9 @@ fun NewRequests(requests: List<Room>) {
|
|||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatRoom(room: Room, onClick: () -> Unit) {
|
fun ChatRoom(room: Room, onClick: () -> Unit) {
|
||||||
val viewModel = LocalNostrViewModel.current
|
val profileViewModel = LocalProfileViewModel.current
|
||||||
val displayName by remember(room) { room.nameFlow(viewModel) }.collectAsStateWithLifecycle("Loading...")
|
val displayName by remember(room) { room.nameFlow(profileViewModel) }.collectAsStateWithLifecycle("Loading...")
|
||||||
val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsStateWithLifecycle(null)
|
val picture by remember(room) { room.pictureFlow(profileViewModel) }.collectAsStateWithLifecycle(null)
|
||||||
|
|
||||||
ListItem(
|
ListItem(
|
||||||
modifier = Modifier.clickable(onClick = onClick),
|
modifier = Modifier.clickable(onClick = onClick),
|
||||||
@@ -760,7 +768,7 @@ fun BottomMenuList(
|
|||||||
onDismiss: (suspend () -> Unit) -> Unit
|
onDismiss: (suspend () -> Unit) -> Unit
|
||||||
) {
|
) {
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val accountViewModel = LocalAccountViewModel.current
|
||||||
|
|
||||||
val defaultMenuList = listOf(
|
val defaultMenuList = listOf(
|
||||||
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
|
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
|
||||||
@@ -790,7 +798,7 @@ fun BottomMenuList(
|
|||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.size(16.dp))
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = { onDismiss { viewModel.logout() } },
|
onClick = { onDismiss { accountViewModel.logout() } },
|
||||||
colors = ButtonDefaults.filledTonalButtonColors(
|
colors = ButtonDefaults.filledTonalButtonColors(
|
||||||
containerColor = MaterialTheme.colorScheme.error,
|
containerColor = MaterialTheme.colorScheme.error,
|
||||||
contentColor = MaterialTheme.colorScheme.onError
|
contentColor = MaterialTheme.colorScheme.onError
|
||||||
|
|||||||
@@ -58,8 +58,10 @@ import org.jetbrains.compose.resources.painterResource
|
|||||||
import rust.nostr.sdk.Keys
|
import rust.nostr.sdk.Keys
|
||||||
import rust.nostr.sdk.NostrConnectUri
|
import rust.nostr.sdk.NostrConnectUri
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import su.reya.coop.LocalAccountViewModel
|
||||||
|
import su.reya.coop.LocalAppViewModel
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalProfileViewModel
|
||||||
import su.reya.coop.LocalScanResult
|
import su.reya.coop.LocalScanResult
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
@@ -74,17 +76,19 @@ fun ImportScreen() {
|
|||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val qrScanResult = LocalScanResult.current
|
val qrScanResult = LocalScanResult.current
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val appViewModel = LocalAppViewModel.current
|
||||||
|
val accountViewModel = LocalAccountViewModel.current
|
||||||
|
val profileViewModel = LocalProfileViewModel.current
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val isBusy by viewModel.isBusy.collectAsStateWithLifecycle(false)
|
val isBusy by appViewModel.isBusy.collectAsStateWithLifecycle(false)
|
||||||
var secret by remember { mutableStateOf("") }
|
var secret by remember { mutableStateOf("") }
|
||||||
var pubkey by remember { mutableStateOf<PublicKey?>(null) }
|
var pubkey by remember { mutableStateOf<PublicKey?>(null) }
|
||||||
|
|
||||||
// Get metadata when pubkey changes
|
// Get metadata when pubkey changes
|
||||||
val metadata by remember(pubkey) {
|
val metadata by remember(pubkey) {
|
||||||
pubkey?.let(viewModel::getMetadata) ?: flowOf(null)
|
pubkey?.let(profileViewModel::getMetadata) ?: flowOf(null)
|
||||||
}.collectAsStateWithLifecycle(null)
|
}.collectAsStateWithLifecycle(null)
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
val profile = metadata?.asRecord()
|
||||||
@@ -246,10 +250,10 @@ fun ImportScreen() {
|
|||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (pubkey == null) {
|
if (pubkey == null) {
|
||||||
viewModel.verifyIdentity(secret).let { pubkey = it }
|
accountViewModel.verifyIdentity(secret).let { pubkey = it }
|
||||||
} else {
|
} else {
|
||||||
// Import the identity
|
// Import the identity
|
||||||
viewModel.importIdentity(secret)
|
accountViewModel.importIdentity(secret)
|
||||||
// Navigate to the home screen
|
// Navigate to the home screen
|
||||||
navigator.navigate(Screen.Home)
|
navigator.navigate(Screen.Home)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,16 +19,16 @@ import coop.composeapp.generated.resources.Res
|
|||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
import io.github.alexzhirkevich.qrose.rememberQrCodePainter
|
import io.github.alexzhirkevich.qrose.rememberQrCodePainter
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
import su.reya.coop.LocalAccountViewModel
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MyQrScreen() {
|
fun MyQrScreen() {
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val accountViewModel = LocalAccountViewModel.current
|
||||||
val currentUser = viewModel.currentUser() ?: return
|
val currentUser = accountViewModel.nostr.signer.currentUser ?: return
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
import coop.composeapp.generated.resources.ic_arrow_next
|
import coop.composeapp.generated.resources.ic_arrow_next
|
||||||
@@ -54,8 +55,9 @@ import coop.composeapp.generated.resources.ic_scanner
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import su.reya.coop.LocalChatViewModel
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalProfileViewModel
|
||||||
import su.reya.coop.LocalScanResult
|
import su.reya.coop.LocalScanResult
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
@@ -69,13 +71,15 @@ fun NewChatScreen() {
|
|||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val qrScanResult = LocalScanResult.current
|
val qrScanResult = LocalScanResult.current
|
||||||
val 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 createGroup = remember { mutableStateOf(false) }
|
||||||
val searchResults = remember { mutableStateListOf<PublicKey>() }
|
val searchResults = remember { mutableStateListOf<PublicKey>() }
|
||||||
val selectedReceivers = remember { mutableStateListOf<PublicKey>() }
|
val selectedReceivers = remember { mutableStateListOf<PublicKey>() }
|
||||||
var query by remember { mutableStateOf("") }
|
|
||||||
|
|
||||||
LaunchedEffect(query) {
|
LaunchedEffect(query) {
|
||||||
if (query.length >= 3) {
|
if (query.length >= 3) {
|
||||||
@@ -92,12 +96,12 @@ fun NewChatScreen() {
|
|||||||
selectedReceivers.add(pubkey)
|
selectedReceivers.add(pubkey)
|
||||||
}
|
}
|
||||||
} else if (query.contains("@")) {
|
} else if (query.contains("@")) {
|
||||||
val pubkey = viewModel.searchByAddress(query)
|
val pubkey = profileViewModel.searchByAddress(query)
|
||||||
if (pubkey != null) {
|
if (pubkey != null) {
|
||||||
selectedReceivers.add(pubkey)
|
selectedReceivers.add(pubkey)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val results = viewModel.searchByNostr(query)
|
val results = profileViewModel.searchByNostr(query)
|
||||||
searchResults.clear()
|
searchResults.clear()
|
||||||
searchResults.addAll(results)
|
searchResults.addAll(results)
|
||||||
}
|
}
|
||||||
@@ -167,7 +171,7 @@ fun NewChatScreen() {
|
|||||||
) {
|
) {
|
||||||
ExtendedFloatingActionButton(
|
ExtendedFloatingActionButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
val roomId = viewModel.createChatRoom(selectedReceivers.toList())
|
val roomId = chatViewModel.createChatRoom(selectedReceivers.toList())
|
||||||
navigator.navigate(Screen.Chat(roomId))
|
navigator.navigate(Screen.Chat(roomId))
|
||||||
},
|
},
|
||||||
expanded = false,
|
expanded = false,
|
||||||
@@ -258,7 +262,7 @@ fun NewChatScreen() {
|
|||||||
items = searchResults,
|
items = searchResults,
|
||||||
selectedReceivers = selectedReceivers,
|
selectedReceivers = selectedReceivers,
|
||||||
onContactClick = { pubkey ->
|
onContactClick = { pubkey ->
|
||||||
val roomId = viewModel.createChatRoom(listOf(pubkey))
|
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
|
||||||
navigator.navigate(Screen.Chat(roomId))
|
navigator.navigate(Screen.Chat(roomId))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -269,7 +273,7 @@ fun NewChatScreen() {
|
|||||||
items = contactList.toList(),
|
items = contactList.toList(),
|
||||||
selectedReceivers = selectedReceivers,
|
selectedReceivers = selectedReceivers,
|
||||||
onContactClick = { pubkey ->
|
onContactClick = { pubkey ->
|
||||||
val roomId = viewModel.createChatRoom(listOf(pubkey))
|
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
|
||||||
navigator.navigate(Screen.Chat(roomId))
|
navigator.navigate(Screen.Chat(roomId))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -286,8 +290,8 @@ fun ReceiverChip(
|
|||||||
pubkey: PublicKey,
|
pubkey: PublicKey,
|
||||||
onRemove: () -> Unit
|
onRemove: () -> Unit
|
||||||
) {
|
) {
|
||||||
val viewModel = LocalNostrViewModel.current
|
val profileViewModel = LocalProfileViewModel.current
|
||||||
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
|
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) }
|
||||||
val metadata by metadataFlow.collectAsState(initial = null)
|
val metadata by metadataFlow.collectAsState(initial = null)
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
val profile = metadata?.asRecord()
|
||||||
@@ -372,8 +376,8 @@ fun ContactListItem(
|
|||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit
|
onLongClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
val viewModel = LocalNostrViewModel.current
|
val profileViewModel = LocalProfileViewModel.current
|
||||||
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
|
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) }
|
||||||
val metadata by metadataFlow.collectAsState(initial = null)
|
val metadata by metadataFlow.collectAsState(initial = null)
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
val profile = metadata?.asRecord()
|
||||||
|
|||||||
@@ -5,17 +5,21 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import su.reya.coop.LocalAccountViewModel
|
||||||
|
import su.reya.coop.LocalAppViewModel
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalProfileViewModel
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.shared.ProfileEditor
|
import su.reya.coop.shared.ProfileEditor
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NewIdentityScreen() {
|
fun NewIdentityScreen() {
|
||||||
val viewModel = LocalNostrViewModel.current
|
val appViewModel = LocalAppViewModel.current
|
||||||
|
val accountViewModel = LocalAccountViewModel.current
|
||||||
|
val profileViewModel = LocalProfileViewModel.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val isBusy by viewModel.isBusy.collectAsStateWithLifecycle(false)
|
val isBusy by appViewModel.isBusy.collectAsStateWithLifecycle(false)
|
||||||
|
|
||||||
ProfileEditor(
|
ProfileEditor(
|
||||||
title = "Create a new identity",
|
title = "Create a new identity",
|
||||||
@@ -24,7 +28,7 @@ fun NewIdentityScreen() {
|
|||||||
onBack = { navigator.goBack() },
|
onBack = { navigator.goBack() },
|
||||||
onConfirm = { name, bio, bytes, type ->
|
onConfirm = { name, bio, bytes, type ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
viewModel.createIdentity(name, bio, bytes, type)
|
accountViewModel.createIdentity(name, bio, bytes, type, profileViewModel)
|
||||||
navigator.navigate(Screen.Home)
|
navigator.navigate(Screen.Home)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ import coop.composeapp.generated.resources.Res
|
|||||||
import coop.composeapp.generated.resources.coop
|
import coop.composeapp.generated.resources.coop
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
import su.reya.coop.LocalAccountViewModel
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.shared.getExpressiveFontFamily
|
import su.reya.coop.shared.getExpressiveFontFamily
|
||||||
@@ -57,7 +57,7 @@ fun OnboardingScreen() {
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val accountViewModel = LocalAccountViewModel.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val logoPainter = painterResource(Res.drawable.coop)
|
val logoPainter = painterResource(Res.drawable.coop)
|
||||||
@@ -158,9 +158,9 @@ fun OnboardingScreen() {
|
|||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (viewModel.isExternalSignerAvailable()) {
|
if (accountViewModel.isExternalSignerAvailable()) {
|
||||||
try {
|
try {
|
||||||
viewModel.connectExternalSigner()
|
accountViewModel.connectExternalSigner()
|
||||||
navigator.navigate(Screen.Home)
|
navigator.navigate(Screen.Home)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.message?.let { snackbarHostState.showSnackbar(it) }
|
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||||
|
|||||||
@@ -44,8 +44,9 @@ import coop.composeapp.generated.resources.ic_share
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import su.reya.coop.LocalChatViewModel
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalProfileViewModel
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
@@ -58,12 +59,13 @@ fun ProfileScreen(pubkey: String) {
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val chatViewModel = LocalChatViewModel.current
|
||||||
|
val profileViewModel = LocalProfileViewModel.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
|
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 metadata by metadataFlow.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
val profile = metadata?.asRecord()
|
||||||
@@ -159,7 +161,7 @@ fun ProfileScreen(pubkey: String) {
|
|||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
val roomId = viewModel.createChatRoom(listOf(pubkey))
|
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
|
||||||
navigator.navigate(Screen.Chat(roomId))
|
navigator.navigate(Screen.Chat(roomId))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.message?.let { snackbarHostState.showSnackbar(it) }
|
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ import org.jetbrains.compose.resources.painterResource
|
|||||||
import rust.nostr.sdk.RelayMetadata
|
import rust.nostr.sdk.RelayMetadata
|
||||||
import rust.nostr.sdk.RelayUrl
|
import rust.nostr.sdk.RelayUrl
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalRelayViewModel
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@@ -74,7 +74,7 @@ import su.reya.coop.LocalSnackbarHostState
|
|||||||
fun RelayScreen() {
|
fun RelayScreen() {
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val relayViewModel = LocalRelayViewModel.current
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val msgRelayList = remember { mutableStateListOf<RelayUrl>() }
|
val msgRelayList = remember { mutableStateListOf<RelayUrl>() }
|
||||||
@@ -96,8 +96,8 @@ fun RelayScreen() {
|
|||||||
var relayToDelete by remember { mutableStateOf<String?>(null) }
|
var relayToDelete by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
relayList.putAll(viewModel.currentUserRelayList())
|
relayList.putAll(relayViewModel.currentUserRelayList())
|
||||||
msgRelayList.addAll(viewModel.currentUserMsgRelayList())
|
msgRelayList.addAll(relayViewModel.currentUserMsgRelayList())
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -321,11 +321,11 @@ fun RelayScreen() {
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
viewModel.removeMsgRelay(relayToDelete!!)
|
relayViewModel.removeMsgRelay(relayToDelete!!)
|
||||||
msgRelayList.removeIf { it.toString() == relayToDelete }
|
msgRelayList.removeIf { it.toString() == relayToDelete }
|
||||||
relayToDelete = null
|
relayToDelete = null
|
||||||
} catch (e: Exception) {
|
} 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,
|
onMsgRelayAdded: (newRelay: String) -> Unit,
|
||||||
onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit,
|
onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit,
|
||||||
) {
|
) {
|
||||||
val viewModel = LocalNostrViewModel.current
|
val relayViewModel = LocalRelayViewModel.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -401,17 +401,17 @@ fun AddRelayDialog(
|
|||||||
if (!isError) {
|
if (!isError) {
|
||||||
when (selected) {
|
when (selected) {
|
||||||
"Messaging" -> {
|
"Messaging" -> {
|
||||||
viewModel.addMsgRelay(relayAddress)
|
relayViewModel.addMsgRelay(relayAddress)
|
||||||
onMsgRelayAdded(relayAddress)
|
onMsgRelayAdded(relayAddress)
|
||||||
}
|
}
|
||||||
|
|
||||||
"Inbox" -> {
|
"Inbox" -> {
|
||||||
viewModel.addInboxRelay(relayAddress)
|
relayViewModel.addInboxRelay(relayAddress)
|
||||||
onRelayAdded(relayAddress, RelayMetadata.WRITE)
|
onRelayAdded(relayAddress, RelayMetadata.WRITE)
|
||||||
}
|
}
|
||||||
|
|
||||||
"Outbox" -> {
|
"Outbox" -> {
|
||||||
viewModel.addOutboxRelay(relayAddress)
|
relayViewModel.addOutboxRelay(relayAddress)
|
||||||
onRelayAdded(relayAddress, RelayMetadata.READ)
|
onRelayAdded(relayAddress, RelayMetadata.READ)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,25 +37,25 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import su.reya.coop.LocalChatViewModel
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.RoomKind
|
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
|
import su.reya.coop.nostr.RoomKind
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RequestListScreen() {
|
fun RequestListScreen() {
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val chatViewModel = LocalChatViewModel.current
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val pullToRefreshState = rememberPullToRefreshState()
|
val pullToRefreshState = rememberPullToRefreshState()
|
||||||
|
|
||||||
var isRefreshing by remember { mutableStateOf(false) }
|
var isRefreshing by remember { mutableStateOf(false) }
|
||||||
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
|
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// Get all request rooms
|
// Get all request rooms
|
||||||
val requests = remember(chatRooms) {
|
val requests = remember(chatRooms) {
|
||||||
@@ -103,7 +103,7 @@ fun RequestListScreen() {
|
|||||||
onRefresh = {
|
onRefresh = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
isRefreshing = true
|
isRefreshing = true
|
||||||
viewModel.refreshChatRooms()
|
chatViewModel.refreshChatRooms()
|
||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,24 +1,27 @@
|
|||||||
package su.reya.coop.screens
|
package su.reya.coop.screens
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import su.reya.coop.LocalAccountViewModel
|
||||||
|
import su.reya.coop.LocalAppViewModel
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalProfileViewModel
|
||||||
import su.reya.coop.shared.ProfileEditor
|
import su.reya.coop.shared.ProfileEditor
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun UpdateProfileScreen() {
|
fun UpdateProfileScreen() {
|
||||||
val viewModel = LocalNostrViewModel.current
|
val appViewModel = LocalAppViewModel.current
|
||||||
|
val accountViewModel = LocalAccountViewModel.current
|
||||||
|
val profileViewModel = LocalProfileViewModel.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val currentUser = viewModel.currentUser() ?: return
|
val currentUser = accountViewModel.nostr.signer.currentUser ?: return
|
||||||
val metadata by viewModel.getMetadata(currentUser).collectAsState(initial = null)
|
val metadata by profileViewModel.getMetadata(currentUser).collectAsStateWithLifecycle()
|
||||||
val isBusy by viewModel.isBusy.collectAsStateWithLifecycle(false)
|
val isBusy by appViewModel.isBusy.collectAsStateWithLifecycle(false)
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
val profile = metadata?.asRecord()
|
||||||
|
|
||||||
@@ -32,7 +35,7 @@ fun UpdateProfileScreen() {
|
|||||||
onBack = { navigator.goBack() },
|
onBack = { navigator.goBack() },
|
||||||
onConfirm = { name, bio, bytes, type ->
|
onConfirm = { name, bio, bytes, type ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
viewModel.updateProfile(name, bio, bytes, type)
|
profileViewModel.updateProfile(name, bio, bytes, type)
|
||||||
navigator.goBack()
|
navigator.goBack()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,18 +4,22 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import su.reya.coop.NostrViewModel
|
import rust.nostr.sdk.PublicKey
|
||||||
import su.reya.coop.Room
|
import su.reya.coop.nostr.Room
|
||||||
import su.reya.coop.short
|
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
|
// Return early if there's a custom subject/room name
|
||||||
subject?.takeIf { it.isNotBlank() }?.let { return flowOf(it) }
|
subject?.takeIf { it.isNotBlank() }?.let { return flowOf(it) }
|
||||||
|
|
||||||
val displayMembers = if (isGroup()) members.take(2) else members.take(1)
|
val displayMembers = if (isGroup()) members.take(2) else members.take(1)
|
||||||
if (displayMembers.isEmpty()) return flowOf("Unknown")
|
if (displayMembers.isEmpty()) return flowOf("Unknown")
|
||||||
|
|
||||||
return combine(displayMembers.map { viewModel.getMetadata(it) }) { metadataArray ->
|
return combine(displayMembers.map { profileViewModel.getMetadata(it) }) { metadataArray ->
|
||||||
val names = metadataArray.mapIndexed { i, metadata ->
|
val names = metadataArray.mapIndexed { i, metadata ->
|
||||||
val profile = metadata?.asRecord()
|
val profile = metadata?.asRecord()
|
||||||
profile?.displayName?.takeIf { it.isNotBlank() }
|
profile?.displayName?.takeIf { it.isNotBlank() }
|
||||||
@@ -29,12 +33,12 @@ fun Room.nameFlow(viewModel: NostrViewModel): Flow<String> {
|
|||||||
if (extraCount > 0) "$combined, +$extraCount" else combined
|
if (extraCount > 0) "$combined, +$extraCount" else combined
|
||||||
} else {
|
} else {
|
||||||
val name = names.first()
|
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)
|
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)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,899 +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.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.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.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.contactListUpdates.collect { contacts ->
|
|
||||||
_contactList.value = contacts.toSet()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Observe metadata updates
|
|
||||||
launch {
|
|
||||||
nostr.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.fetchMetadataBatch(keysToRequest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getCacheMetadata() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
// Wait until the client is ready
|
|
||||||
nostr.waitUntilInitialized()
|
|
||||||
|
|
||||||
val results = nostr.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.getChatRooms() ?: emptySet()
|
|
||||||
if (rooms.isNotEmpty()) {
|
|
||||||
mergeChatRooms(rooms)
|
|
||||||
_isPartialProcessedGiftWrap.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all metadata for the current user
|
|
||||||
nostr.getUserMetadata()
|
|
||||||
|
|
||||||
// Small delay to ensure all relays are connected
|
|
||||||
delay(2.seconds)
|
|
||||||
|
|
||||||
// Check if the relay list is empty
|
|
||||||
val relays = nostr.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.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.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.fetchMsgRelays(pubkey)
|
|
||||||
if (relays.isNotEmpty()) dismissRelayWarning()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun useDefaultMsgRelayList() {
|
|
||||||
try {
|
|
||||||
val defaultRelays = nostr.getDefaultMsgRelayList()
|
|
||||||
nostr.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.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.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.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.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.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.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.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.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.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.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.getChatRooms() ?: emptySet()
|
|
||||||
mergeChatRooms(rooms)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun refreshChatRooms() {
|
|
||||||
try {
|
|
||||||
val rooms = nostr.getChatRooms() ?: emptySet()
|
|
||||||
mergeChatRooms(rooms)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> {
|
|
||||||
try {
|
|
||||||
return nostr.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.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.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.rumorMap[id]
|
|
||||||
|
|
||||||
if (giftWrapId != null) {
|
|
||||||
val isSent = nostr.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.searchByAddress(query)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun searchByNostr(query: String): List<PublicKey> {
|
|
||||||
try {
|
|
||||||
return nostr.searchByNostr(query)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
return emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun verifyActivity(pubkey: PublicKey): Timestamp? {
|
|
||||||
return try {
|
|
||||||
nostr.verifyActivity(pubkey)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun verifyContact(pubkey: PublicKey): Boolean {
|
|
||||||
return try {
|
|
||||||
nostr.verifyContact(pubkey)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun mutualContacts(pubkey: PublicKey): Set<PublicKey> {
|
|
||||||
return try {
|
|
||||||
nostr.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)
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package su.reya.coop
|
package su.reya.coop.nostr
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package su.reya.coop
|
package su.reya.coop.nostr
|
||||||
|
|
||||||
import rust.nostr.sdk.AsyncNostrSigner
|
import rust.nostr.sdk.AsyncNostrSigner
|
||||||
import rust.nostr.sdk.Event
|
import rust.nostr.sdk.Event
|
||||||
336
shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt
Normal file
336
shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
package su.reya.coop.nostr
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import rust.nostr.sdk.AckPolicy
|
||||||
|
import rust.nostr.sdk.Alphabet
|
||||||
|
import rust.nostr.sdk.Client
|
||||||
|
import rust.nostr.sdk.Event
|
||||||
|
import rust.nostr.sdk.EventBuilder
|
||||||
|
import rust.nostr.sdk.EventId
|
||||||
|
import rust.nostr.sdk.Filter
|
||||||
|
import rust.nostr.sdk.Keys
|
||||||
|
import rust.nostr.sdk.Kind
|
||||||
|
import rust.nostr.sdk.KindStandard
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.RelayCapabilities
|
||||||
|
import rust.nostr.sdk.RelayUrl
|
||||||
|
import rust.nostr.sdk.ReqExitPolicy
|
||||||
|
import rust.nostr.sdk.ReqTarget
|
||||||
|
import rust.nostr.sdk.SendEventTarget
|
||||||
|
import rust.nostr.sdk.SingleLetterTag
|
||||||
|
import rust.nostr.sdk.Tag
|
||||||
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
|
import rust.nostr.sdk.nip17ExtractRelayList
|
||||||
|
import rust.nostr.sdk.nip59MakeGiftWrapAsync
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
data class MessageSyncState(
|
||||||
|
val processedCount: Int = 0,
|
||||||
|
val isSyncing: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
class MessageManager(private val nostr: Nostr) {
|
||||||
|
private val client: Client? get() = nostr.client
|
||||||
|
private val signer: UniversalSigner get() = nostr.signer
|
||||||
|
|
||||||
|
val sentEvents: MutableMap<EventId, List<RelayUrl>> = mutableMapOf()
|
||||||
|
val rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
|
||||||
|
|
||||||
|
private val _messageSyncState = MutableStateFlow(MessageSyncState())
|
||||||
|
val messageSyncState = _messageSyncState.asStateFlow()
|
||||||
|
|
||||||
|
fun updateSyncState(update: (MessageSyncState) -> MessageSyncState) {
|
||||||
|
_messageSyncState.update(update)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getUserMessages(msgRelayList: Event) {
|
||||||
|
try {
|
||||||
|
val author = signer.currentUser ?: throw IllegalStateException("User not signed in")
|
||||||
|
val relays = nip17ExtractRelayList(msgRelayList)
|
||||||
|
|
||||||
|
// Ensure relay connections
|
||||||
|
relays.forEach { relay ->
|
||||||
|
client?.addRelay(relay)
|
||||||
|
client?.connectRelay(relay)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct a filter for gift wrap events
|
||||||
|
val filter = Filter().kind(Kind.fromStd(KindStandard.GIFT_WRAP)).pubkey(author)
|
||||||
|
val target = mutableMapOf<RelayUrl, List<Filter>>()
|
||||||
|
relays.forEach { relay ->
|
||||||
|
target[relay] = listOf(filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
client?.subscribe(
|
||||||
|
target = ReqTarget.manual(target),
|
||||||
|
id = "gift-wraps"
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to fetch user messages: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun extractRumor(event: Event): UnsignedEvent? {
|
||||||
|
try {
|
||||||
|
// Gift wrap must have at least one 'p' tag
|
||||||
|
if (event.tags().publicKeys().isEmpty()) {
|
||||||
|
println("No recipient tags found.")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event must be a gift wrap
|
||||||
|
if (event.kind().asStd().let { it != KindStandard.GIFT_WRAP }) {
|
||||||
|
println("Event is not a gift wrap.")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the rumor is already cached
|
||||||
|
val cachedRumor = getCachedRumor(event.id())
|
||||||
|
if (cachedRumor != null) return cachedRumor
|
||||||
|
|
||||||
|
// Decrypt the gift wrap event
|
||||||
|
val seal = signer.nip44DecryptAsync(event.author(), event.content())
|
||||||
|
val sealEvent = Event.fromJson(seal)
|
||||||
|
|
||||||
|
// Verify seal event
|
||||||
|
if (!sealEvent.verify()) {
|
||||||
|
println("Failed to verify seal event.")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt the rumor
|
||||||
|
val rumor = signer.nip44DecryptAsync(sealEvent.author(), sealEvent.content())
|
||||||
|
val unsignedEvent = UnsignedEvent.fromJson(rumor).ensureId()
|
||||||
|
|
||||||
|
// Ensure the rumor author matches the seal
|
||||||
|
if (unsignedEvent.author() != sealEvent.author()) {
|
||||||
|
println("Author mismatch.")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the rumor for later use
|
||||||
|
setCachedRumor(event.id(), unsignedEvent)
|
||||||
|
|
||||||
|
return unsignedEvent
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
println("Failed to unwrap gift ${event.id().toHex()}: ${e.message}")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getCachedRumor(giftId: EventId): UnsignedEvent? {
|
||||||
|
try {
|
||||||
|
val filter = Filter().identifier(giftId.toHex())
|
||||||
|
val event = client?.database()?.query(filter)?.first()
|
||||||
|
|
||||||
|
return event?.content()?.let { UnsignedEvent.fromJson(it) }
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
throw IllegalStateException("Failed to get cached rumor: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
|
||||||
|
try {
|
||||||
|
// Construct reference tags
|
||||||
|
val tags = listOf(
|
||||||
|
Tag.identifier(giftId.toHex()),
|
||||||
|
Tag.publicKey(rumor.author()),
|
||||||
|
Tag.custom("r", listOf(rumor.roomId().toString())),
|
||||||
|
Tag.custom("k", listOf("14"))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Set event kind
|
||||||
|
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
|
||||||
|
|
||||||
|
// Construct event
|
||||||
|
val event = EventBuilder(kind, rumor.asJson())
|
||||||
|
.tags(tags)
|
||||||
|
.finalizeAsync(Keys.generate())
|
||||||
|
|
||||||
|
client?.database()?.saveEvent(event)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
println("Failed to set cached rumor: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getChatRooms(): Set<Room>? {
|
||||||
|
try {
|
||||||
|
val userPubkey =
|
||||||
|
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
|
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
|
||||||
|
val kTag = SingleLetterTag.lowercase(Alphabet.K)
|
||||||
|
|
||||||
|
// Get all DM events
|
||||||
|
val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm"))
|
||||||
|
val events = client?.database()?.query(filter)
|
||||||
|
|
||||||
|
// Collect rooms
|
||||||
|
val roomsMap: MutableMap<Long, Room> = mutableMapOf()
|
||||||
|
|
||||||
|
events
|
||||||
|
?.toVec()
|
||||||
|
?.map { UnsignedEvent.fromJson(it.content()) }
|
||||||
|
?.filter { it.tags().publicKeys().isNotEmpty() }
|
||||||
|
?.forEach { event ->
|
||||||
|
val newRoom = Room.new(rumor = event, userPubkey = userPubkey)
|
||||||
|
val existingRoom = roomsMap[newRoom.id]
|
||||||
|
|
||||||
|
// Check if the room already exists
|
||||||
|
if (existingRoom == null || newRoom.createdAt.asSecs() > existingRoom.createdAt.asSecs()) {
|
||||||
|
val rTag = SingleLetterTag.lowercase(Alphabet.R)
|
||||||
|
val filter = Filter().kind(kind).pubkey(userPubkey)
|
||||||
|
.customTag(rTag, newRoom.id.toString())
|
||||||
|
|
||||||
|
// Determine if it's an ongoing room
|
||||||
|
val isOngoing =
|
||||||
|
client?.database()?.query(filter)?.toVec()?.isNotEmpty() ?: false
|
||||||
|
|
||||||
|
// Append room to map
|
||||||
|
roomsMap[newRoom.id] =
|
||||||
|
if (isOngoing) newRoom.copy(kind = RoomKind.Ongoing) else newRoom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return roomsMap.values.sortedByDescending { it.createdAt.asSecs() }.toSet()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to get chat rooms: ${e.message}")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> {
|
||||||
|
try {
|
||||||
|
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
|
||||||
|
val filter = Filter().kind(kind).reference(roomId.toString())
|
||||||
|
val events = client?.database()?.query(filter)
|
||||||
|
|
||||||
|
// Merge the events
|
||||||
|
return events
|
||||||
|
?.toVec()
|
||||||
|
?.map { UnsignedEvent.fromJson(it.content()).ensureId() }
|
||||||
|
// Filter out events without public keys (receivers)
|
||||||
|
?.filter { it.tags().publicKeys().isNotEmpty() }
|
||||||
|
?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to get chat room messages: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun chatRoomConnect(members: List<PublicKey>) {
|
||||||
|
try {
|
||||||
|
members.forEach { member ->
|
||||||
|
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
||||||
|
val filter = Filter().kind(kind).author(member).limit(1u)
|
||||||
|
|
||||||
|
val stream = client?.streamEvents(
|
||||||
|
target = ReqTarget.auto(listOf(filter)),
|
||||||
|
id = null,
|
||||||
|
timeout = Duration.parse("3s"),
|
||||||
|
policy = ReqExitPolicy.ExitOnEose
|
||||||
|
)
|
||||||
|
|
||||||
|
stream?.next()?.let { res ->
|
||||||
|
val event = res.event ?: return@let
|
||||||
|
connectMsgRelays(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to fetch relays: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun connectMsgRelays(event: Event) {
|
||||||
|
try {
|
||||||
|
val urls = nip17ExtractRelayList(event)
|
||||||
|
for (url in urls) {
|
||||||
|
client?.addRelay(url, RelayCapabilities.gossip())
|
||||||
|
client?.connectRelay(url)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to connect to relays: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun sendMessage(
|
||||||
|
to: Set<PublicKey>,
|
||||||
|
content: String,
|
||||||
|
subject: String? = null,
|
||||||
|
replies: List<EventId> = emptyList(),
|
||||||
|
onRumorCreated: ((UnsignedEvent) -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
val currentUser =
|
||||||
|
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
|
val tags = mutableListOf<Tag>()
|
||||||
|
|
||||||
|
// Add a subject tag if provided
|
||||||
|
if (subject != null) {
|
||||||
|
tags.add(Tag.custom("subject", listOf(subject)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event tags for replies
|
||||||
|
if (replies.isNotEmpty()) {
|
||||||
|
replies.forEach { replyId ->
|
||||||
|
tags.add(Tag.event(replyId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add public key tags for each recipient
|
||||||
|
to.forEach { pubkey ->
|
||||||
|
tags.add(Tag.publicKey(pubkey))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (receiver in setOf(currentUser) + to) {
|
||||||
|
// Construct the rumor event
|
||||||
|
// NEVER SIGN this event with the current user signer
|
||||||
|
val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), content)
|
||||||
|
.tags(tags)
|
||||||
|
.finalizeUnsigned(currentUser)
|
||||||
|
.ensureId()
|
||||||
|
|
||||||
|
// Emit the rumor to the chat screen
|
||||||
|
if (receiver == currentUser) {
|
||||||
|
onRumorCreated?.invoke(rumor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct the gift wrap event
|
||||||
|
val gift = nip59MakeGiftWrapAsync(
|
||||||
|
signer = signer,
|
||||||
|
receiverPubkey = receiver,
|
||||||
|
rumor = rumor,
|
||||||
|
extraTags = listOf(
|
||||||
|
Tag.custom("k", listOf("14"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send the event to receiver's NIP-17 relays
|
||||||
|
val output = client?.sendEvent(
|
||||||
|
event = gift,
|
||||||
|
target = SendEventTarget.toNip17(),
|
||||||
|
ackPolicy = AckPolicy.none(),
|
||||||
|
authenticationTimeout = Duration.parse("2s")
|
||||||
|
)
|
||||||
|
|
||||||
|
if (output != null) {
|
||||||
|
// Keep track of sent events
|
||||||
|
sentEvents[output.id] = emptyList()
|
||||||
|
|
||||||
|
// Keep track of rumor IDs
|
||||||
|
val id = rumor.id() ?: throw IllegalStateException("Rumor ID is null")
|
||||||
|
rumorMap[id] = output.id
|
||||||
|
|
||||||
|
// Collect failed outputs
|
||||||
|
output.failed.forEach { (relayUrl, reason) ->
|
||||||
|
println("Failed to send event to relay $relayUrl: $reason")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to send message: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
265
shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt
Normal file
265
shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
package su.reya.coop.nostr
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.supervisorScope
|
||||||
|
import rust.nostr.sdk.AsyncNostrSigner
|
||||||
|
import rust.nostr.sdk.Client
|
||||||
|
import rust.nostr.sdk.ClientBuilder
|
||||||
|
import rust.nostr.sdk.ClientNotification
|
||||||
|
import rust.nostr.sdk.Event
|
||||||
|
import rust.nostr.sdk.EventId
|
||||||
|
import rust.nostr.sdk.GossipConfig
|
||||||
|
import rust.nostr.sdk.Keys
|
||||||
|
import rust.nostr.sdk.KindStandard
|
||||||
|
import rust.nostr.sdk.LogLevel
|
||||||
|
import rust.nostr.sdk.Metadata
|
||||||
|
import rust.nostr.sdk.NostrDatabase
|
||||||
|
import rust.nostr.sdk.NostrGossip
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.RelayMessageEnum
|
||||||
|
import rust.nostr.sdk.SignerAuthenticator
|
||||||
|
import rust.nostr.sdk.SleepWhenIdle
|
||||||
|
import rust.nostr.sdk.Timestamp
|
||||||
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
|
import rust.nostr.sdk.initLogger
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
object NostrManager {
|
||||||
|
val instance = Nostr()
|
||||||
|
|
||||||
|
val BOOTSTRAP_RELAYS = listOf(
|
||||||
|
"wss://relay.primal.net",
|
||||||
|
"wss://relay.ditto.pub",
|
||||||
|
"wss://user.kindpag.es",
|
||||||
|
)
|
||||||
|
|
||||||
|
val INDEXER_RELAY = listOf(
|
||||||
|
"wss://indexer.coracle.social",
|
||||||
|
)
|
||||||
|
|
||||||
|
val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY
|
||||||
|
}
|
||||||
|
|
||||||
|
class Nostr {
|
||||||
|
var client: Client? = null
|
||||||
|
private set
|
||||||
|
var signer: UniversalSigner = UniversalSigner(Keys.generate())
|
||||||
|
private set
|
||||||
|
|
||||||
|
val messages = MessageManager(this)
|
||||||
|
val profiles = ProfileManager(this)
|
||||||
|
val relays = RelayManager(this)
|
||||||
|
|
||||||
|
private val isInitialized = MutableStateFlow(false)
|
||||||
|
|
||||||
|
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
||||||
|
val newEvents = _newEvents.asSharedFlow()
|
||||||
|
|
||||||
|
suspend fun emitNewEvent(event: UnsignedEvent) {
|
||||||
|
_newEvents.emit(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun init(
|
||||||
|
dbPath: String,
|
||||||
|
logLevel: LogLevel = LogLevel.WARN
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
if (isInitialized.value) return
|
||||||
|
|
||||||
|
// Initialize the logger for nostr client
|
||||||
|
initLogger(logLevel)
|
||||||
|
|
||||||
|
// Initialize configurations for nostr client
|
||||||
|
val lmdb = NostrDatabase.lmdb(dbPath)
|
||||||
|
val gossip = NostrGossip.inMemory()
|
||||||
|
val authenticator = SignerAuthenticator(signer)
|
||||||
|
val idleTimeout = Duration.parse("5m")
|
||||||
|
|
||||||
|
client =
|
||||||
|
ClientBuilder()
|
||||||
|
.authenticator(authenticator)
|
||||||
|
.database(lmdb)
|
||||||
|
.gossip(gossip)
|
||||||
|
.gossipConfig(
|
||||||
|
GossipConfig()
|
||||||
|
.noBackgroundRefresh()
|
||||||
|
.fetchTimeout(Duration.parse("2s"))
|
||||||
|
)
|
||||||
|
.verifySubscriptions(false)
|
||||||
|
.sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
isInitialized.value = true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to initialize Nostr client: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun waitUntilInitialized() {
|
||||||
|
isInitialized.first { it }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun connectBootstrapRelays() {
|
||||||
|
relays.connectBootstrapRelays()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun reconnect() {
|
||||||
|
relays.reconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun disconnect() {
|
||||||
|
relays.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun prune() {
|
||||||
|
try {
|
||||||
|
client?.database()?.wipe()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to prune database: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setSigner(new: AsyncNostrSigner) {
|
||||||
|
try {
|
||||||
|
signer.switch(new)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to set signer: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isSignedByUser(event: Event): Boolean {
|
||||||
|
return try {
|
||||||
|
signer.currentUser == event.author()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to check if event is signed by user: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
suspend fun handleNotifications(
|
||||||
|
onMetadataUpdate: (PublicKey, Metadata) -> Unit,
|
||||||
|
onContactListUpdate: (List<PublicKey>) -> Unit,
|
||||||
|
onNewMessage: (UnsignedEvent) -> Unit,
|
||||||
|
) = supervisorScope {
|
||||||
|
val now = Timestamp.now()
|
||||||
|
val processedEvent = mutableSetOf<EventId>()
|
||||||
|
val notifications = client?.notifications() ?: return@supervisorScope
|
||||||
|
|
||||||
|
val giftWrapQueue = Channel<Event>(Channel.UNLIMITED)
|
||||||
|
var processedCount = 0
|
||||||
|
var eoseReceived = false
|
||||||
|
|
||||||
|
launch(Dispatchers.Default) {
|
||||||
|
for (event in giftWrapQueue) {
|
||||||
|
val rumor = messages.extractRumor(event)
|
||||||
|
processedCount++
|
||||||
|
|
||||||
|
// Trigger new message notification
|
||||||
|
if (rumor != null) {
|
||||||
|
if (rumor.createdAt().asSecs() >= now.asSecs()) {
|
||||||
|
onNewMessage(rumor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update sync state
|
||||||
|
messages.updateSyncState {
|
||||||
|
it.copy(
|
||||||
|
processedCount = processedCount,
|
||||||
|
isSyncing = !eoseReceived || !giftWrapQueue.isEmpty
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
val notification = notifications.next() ?: continue
|
||||||
|
|
||||||
|
when (notification) {
|
||||||
|
is ClientNotification.Message -> {
|
||||||
|
val relayUrl = notification.relayUrl
|
||||||
|
|
||||||
|
when (val message = notification.message.asEnum()) {
|
||||||
|
is RelayMessageEnum.EventMsg -> {
|
||||||
|
val event = message.event
|
||||||
|
|
||||||
|
// Prevent processing duplicate events
|
||||||
|
if (processedEvent.contains(event.id())) continue
|
||||||
|
processedEvent.add(event.id())
|
||||||
|
|
||||||
|
when (event.kind().asStd()) {
|
||||||
|
KindStandard.METADATA -> {
|
||||||
|
try {
|
||||||
|
val metadata = Metadata.fromJson(event.content())
|
||||||
|
onMetadataUpdate(event.author(), metadata)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to parse metadata: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KindStandard.CONTACT_LIST -> {
|
||||||
|
if (isSignedByUser(event = event)) {
|
||||||
|
val pubkeys = event.tags().publicKeys()
|
||||||
|
// Get mutual contacts
|
||||||
|
profiles.getMutualContacts(pubkeys)
|
||||||
|
// Emit contact list update
|
||||||
|
onContactListUpdate(pubkeys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KindStandard.INBOX_RELAYS -> {
|
||||||
|
// Get all gift wrap events for the current user
|
||||||
|
if (isSignedByUser(event = event)) {
|
||||||
|
messages.getUserMessages(msgRelayList = event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KindStandard.GIFT_WRAP -> {
|
||||||
|
giftWrapQueue.send(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is RelayMessageEnum.EndOfStoredEvents -> {
|
||||||
|
if (message.subscriptionId == "gift-wraps") {
|
||||||
|
eoseReceived = true
|
||||||
|
if (giftWrapQueue.isEmpty) {
|
||||||
|
messages.updateSyncState { it.copy(isSyncing = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is RelayMessageEnum.Ok -> {
|
||||||
|
if (messages.sentEvents.containsKey(message.eventId)) {
|
||||||
|
val currentRelays =
|
||||||
|
messages.sentEvents[message.eventId] ?: emptyList()
|
||||||
|
messages.sentEvents[message.eventId] = currentRelays + relayUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
/* Ignore other message types */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is ClientNotification.Shutdown -> {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
/* Ignore other message types */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
package su.reya.coop.nostr
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.statement.HttpResponse
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import rust.nostr.sdk.AckPolicy
|
||||||
|
import rust.nostr.sdk.Client
|
||||||
|
import rust.nostr.sdk.Contact
|
||||||
|
import rust.nostr.sdk.EventBuilder
|
||||||
|
import rust.nostr.sdk.Filter
|
||||||
|
import rust.nostr.sdk.Keys
|
||||||
|
import rust.nostr.sdk.Kind
|
||||||
|
import rust.nostr.sdk.KindStandard
|
||||||
|
import rust.nostr.sdk.Metadata
|
||||||
|
import rust.nostr.sdk.MetadataRecord
|
||||||
|
import rust.nostr.sdk.Nip05Address
|
||||||
|
import rust.nostr.sdk.Nip05Profile
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.RelayCapabilities
|
||||||
|
import rust.nostr.sdk.RelayUrl
|
||||||
|
import rust.nostr.sdk.ReqExitPolicy
|
||||||
|
import rust.nostr.sdk.ReqTarget
|
||||||
|
import rust.nostr.sdk.SendEventTarget
|
||||||
|
import rust.nostr.sdk.SubscribeAutoCloseOptions
|
||||||
|
import rust.nostr.sdk.Timestamp
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
class ProfileManager(private val nostr: Nostr) {
|
||||||
|
private val client: Client? get() = nostr.client
|
||||||
|
private val signer: UniversalSigner get() = nostr.signer
|
||||||
|
|
||||||
|
private val _metadataUpdates =
|
||||||
|
MutableSharedFlow<Pair<PublicKey, Metadata>>(extraBufferCapacity = 100)
|
||||||
|
val metadataUpdates = _metadataUpdates.asSharedFlow()
|
||||||
|
|
||||||
|
private val _contactListUpdates = MutableSharedFlow<List<PublicKey>>(extraBufferCapacity = 100)
|
||||||
|
val contactListUpdates = _contactListUpdates.asSharedFlow()
|
||||||
|
|
||||||
|
suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) {
|
||||||
|
_metadataUpdates.emit(pubkey to metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun emitContactListUpdate(contacts: List<PublicKey>) {
|
||||||
|
_contactListUpdates.emit(contacts)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getUserMetadata() {
|
||||||
|
try {
|
||||||
|
val author = signer.currentUser ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
|
// Get the latest metadata event
|
||||||
|
val metadataFilter =
|
||||||
|
Filter().kind(Kind.fromStd(KindStandard.METADATA)).author(author).limit(1u)
|
||||||
|
|
||||||
|
// Get the latest contact list event
|
||||||
|
val contactFilter =
|
||||||
|
Filter().kind(Kind.fromStd(KindStandard.CONTACT_LIST)).author(author).limit(1u)
|
||||||
|
|
||||||
|
// Get the latest messaging relay list event
|
||||||
|
val msgRelayFilter =
|
||||||
|
Filter().kind(Kind.fromStd(KindStandard.INBOX_RELAYS)).author(author).limit(1u)
|
||||||
|
|
||||||
|
// Construct a target that includes all filters
|
||||||
|
val target = ReqTarget.auto(listOf(metadataFilter, contactFilter, msgRelayFilter))
|
||||||
|
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
||||||
|
|
||||||
|
client?.subscribe(target = target, id = "user-metadata", closeOn = opts)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to fetch user metadata: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getMutualContacts(pubkeys: List<PublicKey>) {
|
||||||
|
try {
|
||||||
|
val kind = Kind.fromStd(KindStandard.CONTACT_LIST)
|
||||||
|
val filter = Filter().kind(kind).authors(pubkeys).limit(pubkeys.size.toULong())
|
||||||
|
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
||||||
|
|
||||||
|
val target = mutableMapOf<RelayUrl, List<Filter>>()
|
||||||
|
NostrManager.BOOTSTRAP_RELAYS.forEach { relay ->
|
||||||
|
target[RelayUrl.parse(relay)] = listOf(filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
client?.subscribe(
|
||||||
|
target = ReqTarget.manual(target),
|
||||||
|
id = "mutual-contacts",
|
||||||
|
closeOn = opts,
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to fetch mutual contacts: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createIdentity(keys: Keys, name: String, bio: String?, picture: String?) {
|
||||||
|
// Send relay list event
|
||||||
|
val relayList = nostr.relays.getDefaultRelayList()
|
||||||
|
val relayListEvent = EventBuilder.relayList(relayList).finalizeAsync(keys)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = relayListEvent,
|
||||||
|
target = SendEventTarget.broadcast(),
|
||||||
|
ackPolicy = AckPolicy.all(),
|
||||||
|
okTimeout = Duration.parse("3s")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send messaging relay list event
|
||||||
|
val msgRelayList = nostr.relays.getDefaultMsgRelayList()
|
||||||
|
val msgRelayListEvent = EventBuilder.nip17RelayList(msgRelayList).finalizeAsync(keys)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = msgRelayListEvent,
|
||||||
|
target = SendEventTarget.toNip65(),
|
||||||
|
ackPolicy = AckPolicy.none()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send metadata event
|
||||||
|
val metadata =
|
||||||
|
Metadata.fromRecord(MetadataRecord(displayName = name, about = bio, picture = picture))
|
||||||
|
val metadataEvent = EventBuilder.metadata(metadata).finalizeAsync(keys)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = metadataEvent,
|
||||||
|
target = SendEventTarget.broadcast(),
|
||||||
|
ackPolicy = AckPolicy.none()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send contact list event
|
||||||
|
val defaultContact =
|
||||||
|
Contact(PublicKey.parse("npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x"))
|
||||||
|
val contactListEvent = EventBuilder.contactList(listOf(defaultContact)).finalizeAsync(keys)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = contactListEvent,
|
||||||
|
target = SendEventTarget.toNip65(),
|
||||||
|
ackPolicy = AckPolicy.none()
|
||||||
|
)
|
||||||
|
|
||||||
|
nostr.setSigner(keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateProfile(
|
||||||
|
name: String? = null,
|
||||||
|
bio: String? = null,
|
||||||
|
picture: String? = null
|
||||||
|
): Metadata {
|
||||||
|
val currentUser = signer.currentUser ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
|
try {
|
||||||
|
val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord()
|
||||||
|
val newRecord = record.copy(
|
||||||
|
displayName = name ?: record.displayName,
|
||||||
|
about = bio ?: record.about,
|
||||||
|
picture = picture ?: record.picture
|
||||||
|
)
|
||||||
|
val newMetadata = Metadata.fromRecord(newRecord)
|
||||||
|
val event = EventBuilder.metadata(newMetadata).finalizeAsync(signer)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = event,
|
||||||
|
target = SendEventTarget.broadcast(),
|
||||||
|
ackPolicy = AckPolicy.none()
|
||||||
|
)
|
||||||
|
|
||||||
|
return newMetadata
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to update identity: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getLatestMetadata(pubkey: PublicKey): Metadata? {
|
||||||
|
return try {
|
||||||
|
val kind = Kind.fromStd(KindStandard.METADATA)
|
||||||
|
val filter = Filter().kind(kind).author(pubkey).limit(1u)
|
||||||
|
val event = client?.database()?.query(filter)?.first() ?: return null
|
||||||
|
|
||||||
|
Metadata.fromJson(event.content())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to get latest metadata: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getAllCacheMetadata(): Map<PublicKey, Metadata> {
|
||||||
|
try {
|
||||||
|
val filter = Filter().kind(Kind.fromStd(KindStandard.METADATA)).limit(100u)
|
||||||
|
val events = client?.database()?.query(filter)
|
||||||
|
val results = mutableMapOf<PublicKey, Metadata>()
|
||||||
|
|
||||||
|
events?.toVec()?.forEach { event ->
|
||||||
|
try {
|
||||||
|
val metadata = Metadata.fromJson(event.content())
|
||||||
|
results[event.author()] = metadata
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to parse metadata: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to get all cache metadata: ${e.message}")
|
||||||
|
return emptyMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetchMetadataBatch(keys: List<PublicKey>) {
|
||||||
|
try {
|
||||||
|
val limit = keys.size.toULong() * 2u
|
||||||
|
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
||||||
|
|
||||||
|
// Construct a filter for metadata events
|
||||||
|
val filter = Filter()
|
||||||
|
.kind(Kind.fromStd(KindStandard.METADATA))
|
||||||
|
.authors(keys)
|
||||||
|
.limit(limit)
|
||||||
|
|
||||||
|
// Construct request target
|
||||||
|
val target = mutableMapOf<RelayUrl, List<Filter>>()
|
||||||
|
NostrManager.BOOTSTRAP_RELAYS.forEach { relay ->
|
||||||
|
target[RelayUrl.parse(relay)] = listOf(filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
client?.subscribe(target = ReqTarget.manual(target), closeOn = opts)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to fetch metadata batch: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setContactList(contacts: List<PublicKey>) {
|
||||||
|
try {
|
||||||
|
val contactList = contacts.map { Contact(it) }
|
||||||
|
val event = EventBuilder.contactList(contactList).finalizeAsync(signer)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = event,
|
||||||
|
target = SendEventTarget.broadcast(),
|
||||||
|
ackPolicy = AckPolicy.none(),
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to set contact list: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun profileFromAddress(client: HttpClient, address: Nip05Address): Nip05Profile {
|
||||||
|
try {
|
||||||
|
val response: HttpResponse = client.get(address.url())
|
||||||
|
val bodyString: String = response.body()
|
||||||
|
|
||||||
|
return Nip05Profile.fromJson(address, bodyString)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to fetch profile from address: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun searchByAddress(query: String): PublicKey {
|
||||||
|
try {
|
||||||
|
val address = Nip05Address.parse(query)
|
||||||
|
val profile = profileFromAddress(HttpClient(), address)
|
||||||
|
|
||||||
|
return profile.publicKey()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to search address: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun searchByNostr(query: String): List<PublicKey> {
|
||||||
|
try {
|
||||||
|
// Add search relay
|
||||||
|
val searchRelay = RelayUrl.parse("wss://antiprimal.net")
|
||||||
|
if (client?.relay(searchRelay) == null) {
|
||||||
|
client?.addRelay(url = searchRelay, capabilities = RelayCapabilities.read())
|
||||||
|
client?.connectRelay(searchRelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
val kinds = listOf(Kind.fromStd(KindStandard.METADATA))
|
||||||
|
val filter = Filter().kinds(kinds).search(query).limit(10u)
|
||||||
|
val target = ReqTarget.manual(mapOf(searchRelay to listOf(filter)))
|
||||||
|
|
||||||
|
val stream = client?.streamEvents(
|
||||||
|
target = target,
|
||||||
|
id = "search",
|
||||||
|
timeout = Duration.parse("3s"),
|
||||||
|
policy = ReqExitPolicy.ExitOnEose
|
||||||
|
)
|
||||||
|
|
||||||
|
// Collect the results
|
||||||
|
val results = mutableListOf<PublicKey>()
|
||||||
|
|
||||||
|
// Keep searching until the stream is closed or timeout
|
||||||
|
stream?.next()?.let { event ->
|
||||||
|
val event = event.event ?: return@let
|
||||||
|
results.add(event.author())
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to search nostr: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun verifyActivity(pubkey: PublicKey): Timestamp? {
|
||||||
|
try {
|
||||||
|
val filter = Filter().author(pubkey).limit(3u)
|
||||||
|
val target = mutableMapOf<RelayUrl, List<Filter>>()
|
||||||
|
NostrManager.BOOTSTRAP_RELAYS.forEach { relay ->
|
||||||
|
target[RelayUrl.parse(relay)] = listOf(filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
val events = client?.fetchEvents(
|
||||||
|
target = ReqTarget.manual(target),
|
||||||
|
timeout = Duration.parse("3s")
|
||||||
|
)
|
||||||
|
|
||||||
|
return events?.first()?.createdAt()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to get latest activity: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun verifyContact(pubkey: PublicKey): Boolean {
|
||||||
|
try {
|
||||||
|
val currentUser =
|
||||||
|
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
|
val kind = Kind.fromStd(KindStandard.CONTACT_LIST)
|
||||||
|
val filter = Filter().kind(kind).author(currentUser).limit(1u)
|
||||||
|
|
||||||
|
val events = client?.database()?.query(filter)
|
||||||
|
val pubkeys = events?.first()?.tags()?.publicKeys() ?: listOf()
|
||||||
|
|
||||||
|
return pubkeys.contains(pubkey)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun mutualContacts(pubkey: PublicKey): Set<PublicKey> {
|
||||||
|
try {
|
||||||
|
val currentUser =
|
||||||
|
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
|
val kind = Kind.fromStd(KindStandard.CONTACT_LIST)
|
||||||
|
val filter = Filter().kind(kind).pubkey(pubkey).limit(1u)
|
||||||
|
|
||||||
|
val events = client?.database()?.query(filter)
|
||||||
|
val contacts = mutableSetOf<PublicKey>()
|
||||||
|
|
||||||
|
events?.toVec()?.filter { it.author() != currentUser }?.forEach { event ->
|
||||||
|
contacts.add(event.author())
|
||||||
|
}
|
||||||
|
|
||||||
|
return contacts.toSet()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
182
shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt
Normal file
182
shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
package su.reya.coop.nostr
|
||||||
|
|
||||||
|
import rust.nostr.sdk.AckPolicy
|
||||||
|
import rust.nostr.sdk.Client
|
||||||
|
import rust.nostr.sdk.EventBuilder
|
||||||
|
import rust.nostr.sdk.Filter
|
||||||
|
import rust.nostr.sdk.Kind
|
||||||
|
import rust.nostr.sdk.KindStandard
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.RelayCapabilities
|
||||||
|
import rust.nostr.sdk.RelayMetadata
|
||||||
|
import rust.nostr.sdk.RelayStatus
|
||||||
|
import rust.nostr.sdk.RelayUrl
|
||||||
|
import rust.nostr.sdk.ReqExitPolicy
|
||||||
|
import rust.nostr.sdk.ReqTarget
|
||||||
|
import rust.nostr.sdk.SendEventTarget
|
||||||
|
import rust.nostr.sdk.SubscribeAutoCloseOptions
|
||||||
|
import rust.nostr.sdk.extractRelayList
|
||||||
|
import rust.nostr.sdk.nip17ExtractRelayList
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
class RelayManager(private val nostr: Nostr) {
|
||||||
|
private val client: Client? get() = nostr.client
|
||||||
|
private val signer: UniversalSigner get() = nostr.signer
|
||||||
|
|
||||||
|
suspend fun connectBootstrapRelays() {
|
||||||
|
NostrManager.BOOTSTRAP_RELAYS.forEach { url ->
|
||||||
|
client?.addRelay(RelayUrl.parse(url))
|
||||||
|
}
|
||||||
|
NostrManager.INDEXER_RELAY.forEach { url ->
|
||||||
|
client?.addRelay(
|
||||||
|
url = RelayUrl.parse(url),
|
||||||
|
capabilities = RelayCapabilities.gossip()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Connect to all bootstrap relays
|
||||||
|
client?.connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun reconnect() {
|
||||||
|
NostrManager.ALL_RELAYS.forEach { url ->
|
||||||
|
try {
|
||||||
|
client?.relay(RelayUrl.parse(url)).let { relay ->
|
||||||
|
if (relay != null) {
|
||||||
|
if (relay.status() != RelayStatus.CONNECTED) {
|
||||||
|
relay.connect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to reconnect relay: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun disconnect() {
|
||||||
|
NostrManager.ALL_RELAYS.forEach { url ->
|
||||||
|
try {
|
||||||
|
client?.disconnectRelay(RelayUrl.parse(url))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to disconnect relay: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal suspend fun getDefaultRelayList(): Map<RelayUrl, RelayMetadata> {
|
||||||
|
// Construct a list of relays
|
||||||
|
val relayList = mapOf(
|
||||||
|
RelayUrl.parse("wss://relay.damus.io") to RelayMetadata.READ,
|
||||||
|
RelayUrl.parse("wss://relay.primal.net") to RelayMetadata.READ,
|
||||||
|
RelayUrl.parse("wss://relay.nostr.net") to RelayMetadata.WRITE,
|
||||||
|
RelayUrl.parse("wss://nostr.superfriends.online") to RelayMetadata.WRITE
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ensure all relays are added and connected
|
||||||
|
relayList.forEach { (relay, metadata) ->
|
||||||
|
client?.addRelay(
|
||||||
|
url = relay,
|
||||||
|
capabilities =
|
||||||
|
when (metadata) {
|
||||||
|
RelayMetadata.READ -> RelayCapabilities.read()
|
||||||
|
RelayMetadata.WRITE -> RelayCapabilities.write()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
client?.connectRelay(relay)
|
||||||
|
}
|
||||||
|
|
||||||
|
return relayList
|
||||||
|
}
|
||||||
|
|
||||||
|
internal suspend fun getDefaultMsgRelayList(): List<RelayUrl> {
|
||||||
|
// Construct a list of messaging relays
|
||||||
|
val msgRelayList = listOf(
|
||||||
|
RelayUrl.parse("wss://auth.nostr1.com"),
|
||||||
|
RelayUrl.parse("wss://nip17.com"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ensure all relays are added and connected
|
||||||
|
msgRelayList.forEach { relay ->
|
||||||
|
client?.addRelay(relay, RelayCapabilities.none())
|
||||||
|
client?.connectRelay(relay)
|
||||||
|
}
|
||||||
|
|
||||||
|
return msgRelayList
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setMsgRelays(urls: List<RelayUrl>) {
|
||||||
|
try {
|
||||||
|
val event = EventBuilder.nip17RelayList(urls).finalizeAsync(signer)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = event,
|
||||||
|
target = SendEventTarget.toNip65(),
|
||||||
|
ackPolicy = AckPolicy.none(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val currentUser =
|
||||||
|
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
|
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
||||||
|
val filter = Filter().kind(kind).author(currentUser).limit(1u)
|
||||||
|
|
||||||
|
val target = ReqTarget.auto(listOf(filter))
|
||||||
|
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
||||||
|
|
||||||
|
client?.subscribe(target = target, closeOn = opts)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to set msg relays: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getMsgRelays(publicKey: PublicKey): List<RelayUrl> {
|
||||||
|
try {
|
||||||
|
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
||||||
|
val filter = Filter().kind(kind).author(publicKey).limit(1u)
|
||||||
|
val events = client?.database()?.query(filter)
|
||||||
|
|
||||||
|
return nip17ExtractRelayList(events?.toVec()?.firstOrNull() ?: return emptyList())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to get msg relays: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetchMsgRelays(publicKey: PublicKey): List<RelayUrl> {
|
||||||
|
try {
|
||||||
|
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
||||||
|
val filter = Filter().kind(kind).author(publicKey).limit(1u)
|
||||||
|
val target = ReqTarget.auto(listOf(filter))
|
||||||
|
val events = client?.fetchEvents(target, timeout = Duration.parse("3s"))
|
||||||
|
|
||||||
|
return nip17ExtractRelayList(events?.toVec()?.firstOrNull() ?: return emptyList())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to fetch msg relays: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getRelayList(publicKey: PublicKey): Map<RelayUrl, RelayMetadata?> {
|
||||||
|
try {
|
||||||
|
val kind = Kind.fromStd(KindStandard.RELAY_LIST)
|
||||||
|
val filter = Filter().kind(kind).author(publicKey).limit(1u)
|
||||||
|
val events = client?.database()?.query(filter)
|
||||||
|
|
||||||
|
return extractRelayList(events?.toVec()?.firstOrNull() ?: return emptyMap())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to get relay list: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setRelaylist(relays: Map<RelayUrl, RelayMetadata?>) {
|
||||||
|
try {
|
||||||
|
val event = EventBuilder.relayList(relays).finalizeAsync(signer)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = event,
|
||||||
|
target = SendEventTarget.broadcast(),
|
||||||
|
ackPolicy = AckPolicy.none(),
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to set msg relays: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package su.reya.coop
|
package su.reya.coop.nostr
|
||||||
|
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package su.reya.coop
|
package su.reya.coop.nostr
|
||||||
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
@@ -63,4 +63,4 @@ class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner {
|
|||||||
override suspend fun nip44DecryptAsync(publicKey: PublicKey, payload: String): String {
|
override suspend fun nip44DecryptAsync(publicKey: PublicKey, payload: String): String {
|
||||||
return get().nip44DecryptAsync(publicKey, payload)
|
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