chore: refactor the internal structure (#33)
Reviewed-on: #33
This commit was merged in pull request #33.
This commit is contained in:
@@ -25,8 +25,8 @@ kotlin {
|
|||||||
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
|
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
|
||||||
implementation(libs.androidx.core.splashscreen)
|
implementation(libs.androidx.core.splashscreen)
|
||||||
implementation("su.reya:nostr-sdk-kmp:0.3.2")
|
implementation("su.reya:nostr-sdk-kmp:0.3.2")
|
||||||
implementation("io.coil-kt.coil3:coil-compose:3.4.0")
|
implementation("io.coil-kt.coil3:coil-compose:3.5.0")
|
||||||
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
|
implementation("io.coil-kt.coil3:coil-network-okhttp:3.5.0")
|
||||||
implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0")
|
implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0")
|
||||||
implementation("io.github.alexzhirkevich:qrose:1.1.2")
|
implementation("io.github.alexzhirkevich:qrose:1.1.2")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import androidx.navigation3.runtime.entryProvider
|
|||||||
import androidx.navigation3.runtime.rememberNavBackStack
|
import androidx.navigation3.runtime.rememberNavBackStack
|
||||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
|
import su.reya.coop.repository.ErrorRepository
|
||||||
import su.reya.coop.screens.ChatScreen
|
import su.reya.coop.screens.ChatScreen
|
||||||
import su.reya.coop.screens.ContactListScreen
|
import su.reya.coop.screens.ContactListScreen
|
||||||
import su.reya.coop.screens.HomeScreen
|
import su.reya.coop.screens.HomeScreen
|
||||||
@@ -47,30 +48,20 @@ 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.viewmodel.AuthViewModel
|
||||||
import su.reya.coop.viewmodels.AppViewModel
|
import su.reya.coop.viewmodel.ChatViewModel
|
||||||
import su.reya.coop.viewmodels.ChatViewModel
|
import su.reya.coop.viewmodel.NostrViewModel
|
||||||
import su.reya.coop.viewmodels.ProfileViewModel
|
|
||||||
import su.reya.coop.viewmodels.RelayViewModel
|
|
||||||
|
|
||||||
val LocalAppViewModel = staticCompositionLocalOf<AppViewModel> {
|
val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
|
||||||
error("No AppViewModel provided")
|
error("No NostrViewModel provided")
|
||||||
}
|
|
||||||
|
|
||||||
val LocalAccountViewModel = staticCompositionLocalOf<AccountViewModel> {
|
|
||||||
error("No AccountViewModel provided")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val LocalChatViewModel = staticCompositionLocalOf<ChatViewModel> {
|
val LocalChatViewModel = staticCompositionLocalOf<ChatViewModel> {
|
||||||
error("No ChatViewModel provided")
|
error("No ChatViewModel provided")
|
||||||
}
|
}
|
||||||
|
|
||||||
val LocalProfileViewModel = staticCompositionLocalOf<ProfileViewModel> {
|
val LocalAuthViewModel = staticCompositionLocalOf<AuthViewModel> {
|
||||||
error("No ProfileViewModel provided")
|
error("No AuthViewModel provided")
|
||||||
}
|
|
||||||
|
|
||||||
val LocalRelayViewModel = staticCompositionLocalOf<RelayViewModel> {
|
|
||||||
error("No RelayViewModel provided")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
|
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
|
||||||
@@ -88,11 +79,9 @@ val LocalScanResult = staticCompositionLocalOf<QrScanResult> {
|
|||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun App(
|
fun App(
|
||||||
appViewModel: AppViewModel,
|
nostrViewModel: NostrViewModel,
|
||||||
accountViewModel: AccountViewModel,
|
|
||||||
chatViewModel: ChatViewModel,
|
chatViewModel: ChatViewModel,
|
||||||
profileViewModel: ProfileViewModel,
|
authViewModel: AuthViewModel,
|
||||||
relayViewModel: RelayViewModel,
|
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val activity = context as? ComponentActivity
|
val activity = context as? ComponentActivity
|
||||||
@@ -101,7 +90,8 @@ fun App(
|
|||||||
val qrScanResult = remember { QrScanResult() }
|
val qrScanResult = remember { QrScanResult() }
|
||||||
|
|
||||||
// Get the signer required state
|
// Get the signer required state
|
||||||
val signerRequired by accountViewModel.signerRequired.collectAsStateWithLifecycle()
|
val authState by authViewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val signerRequired = authState.signerRequired
|
||||||
|
|
||||||
// Snackbar
|
// Snackbar
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
@@ -128,7 +118,7 @@ fun App(
|
|||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
appViewModel.errorEvents.collect { message ->
|
ErrorRepository.errors.collect { message ->
|
||||||
snackbarHostState.showSnackbar(message)
|
snackbarHostState.showSnackbar(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,15 +161,14 @@ fun App(
|
|||||||
motionScheme = MotionScheme.expressive(),
|
motionScheme = MotionScheme.expressive(),
|
||||||
) {
|
) {
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalAppViewModel provides appViewModel,
|
LocalNostrViewModel provides nostrViewModel,
|
||||||
LocalAccountViewModel provides accountViewModel,
|
|
||||||
LocalChatViewModel provides chatViewModel,
|
LocalChatViewModel provides chatViewModel,
|
||||||
LocalProfileViewModel provides profileViewModel,
|
LocalAuthViewModel provides authViewModel,
|
||||||
LocalRelayViewModel provides relayViewModel,
|
|
||||||
LocalSnackbarHostState provides snackbarHostState,
|
LocalSnackbarHostState provides snackbarHostState,
|
||||||
LocalNavigator provides navigator,
|
LocalNavigator provides navigator,
|
||||||
LocalScanResult provides qrScanResult,
|
LocalScanResult provides qrScanResult,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
NavDisplay(
|
NavDisplay(
|
||||||
backStack = backStack,
|
backStack = backStack,
|
||||||
onBack = {
|
onBack = {
|
||||||
|
|||||||
@@ -10,23 +10,13 @@ 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.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.nostr.NostrManager
|
||||||
import su.reya.coop.viewmodels.AccountViewModel
|
import su.reya.coop.viewmodel.AuthViewModel
|
||||||
import su.reya.coop.viewmodels.AppViewModel
|
import su.reya.coop.viewmodel.ChatViewModel
|
||||||
import su.reya.coop.viewmodels.ChatViewModel
|
import su.reya.coop.viewmodel.NostrViewModel
|
||||||
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() {
|
||||||
@@ -36,34 +26,30 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
private val factory by lazy {
|
private val factory by lazy {
|
||||||
object : ViewModelProvider.Factory {
|
object : ViewModelProvider.Factory {
|
||||||
private val secretStore = SecretStore(this@MainActivity)
|
|
||||||
private val androidSigner =
|
private val androidSigner =
|
||||||
AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
|
AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
|
||||||
private val appVM = AppViewModel(NostrManager.instance, secretStore)
|
private val secretStore = SecretStore(this@MainActivity)
|
||||||
private val profileVM = ProfileViewModel(NostrManager.instance, appVM)
|
private val nostrViewModel =
|
||||||
private val chatVM = ChatViewModel(NostrManager.instance, appVM)
|
NostrViewModel(NostrManager.instance)
|
||||||
private val accountVM =
|
private val chatViewModel =
|
||||||
AccountViewModel(NostrManager.instance, secretStore, appVM, androidSigner)
|
ChatViewModel(NostrManager.instance)
|
||||||
private val relayVM = RelayViewModel(NostrManager.instance, appVM, chatVM, profileVM)
|
private val authViewModel =
|
||||||
|
AuthViewModel(NostrManager.instance, secretStore, androidSigner)
|
||||||
|
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
return when {
|
return when {
|
||||||
modelClass.isAssignableFrom(AppViewModel::class.java) -> appVM
|
modelClass.isAssignableFrom(NostrViewModel::class.java) -> nostrViewModel
|
||||||
modelClass.isAssignableFrom(AccountViewModel::class.java) -> accountVM
|
modelClass.isAssignableFrom(ChatViewModel::class.java) -> chatViewModel
|
||||||
modelClass.isAssignableFrom(ChatViewModel::class.java) -> chatVM
|
modelClass.isAssignableFrom(AuthViewModel::class.java) -> authViewModel
|
||||||
modelClass.isAssignableFrom(ProfileViewModel::class.java) -> profileVM
|
|
||||||
modelClass.isAssignableFrom(RelayViewModel::class.java) -> relayVM
|
|
||||||
else -> throw IllegalArgumentException("Unknown ViewModel class")
|
else -> throw IllegalArgumentException("Unknown ViewModel class")
|
||||||
} as T
|
} as T
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val appViewModel: AppViewModel by viewModels { factory }
|
private val nostrViewModel: NostrViewModel by viewModels { factory }
|
||||||
private val accountViewModel: AccountViewModel by viewModels { factory }
|
|
||||||
private val chatViewModel: ChatViewModel by viewModels { factory }
|
private val chatViewModel: ChatViewModel by viewModels { factory }
|
||||||
private val profileViewModel: ProfileViewModel by viewModels { factory }
|
private val authViewModel: AuthViewModel by viewModels { factory }
|
||||||
private val relayViewModel: RelayViewModel by viewModels { factory }
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||||
@@ -102,36 +88,19 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
// Keep the splash screen visible until the signer check is complete
|
// Keep the splash screen visible until the signer check is complete
|
||||||
splashScreen.setKeepOnScreenCondition {
|
splashScreen.setKeepOnScreenCondition {
|
||||||
accountViewModel.signerRequired.value == null
|
authViewModel.state.value.signerRequired == null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bind the lifecycle of the ViewModels
|
|
||||||
val lifecycle = ProcessLifecycleOwner.get().lifecycle
|
|
||||||
lifecycle.addObserver(object : DefaultLifecycleObserver {
|
|
||||||
override fun onStart(owner: LifecycleOwner) {
|
|
||||||
appViewModel.viewModelScope.launch {
|
|
||||||
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
|
||||||
coroutineScope {
|
|
||||||
launch { chatViewModel.bindObservers() }
|
|
||||||
launch { profileViewModel.bindObservers() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
relayViewModel.observeSignerAndCheckRelays()
|
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
App(
|
App(
|
||||||
appViewModel = appViewModel,
|
nostrViewModel = nostrViewModel,
|
||||||
accountViewModel = accountViewModel,
|
|
||||||
chatViewModel = chatViewModel,
|
chatViewModel = chatViewModel,
|
||||||
profileViewModel = profileViewModel,
|
authViewModel = authViewModel,
|
||||||
relayViewModel = relayViewModel,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
setIntent(intent)
|
setIntent(intent)
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ 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.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"
|
||||||
|
|||||||
@@ -70,27 +70,24 @@ import coop.composeapp.generated.resources.ic_cancel
|
|||||||
import coop.composeapp.generated.resources.ic_check_circle
|
import coop.composeapp.generated.resources.ic_check_circle
|
||||||
import coop.composeapp.generated.resources.ic_send
|
import coop.composeapp.generated.resources.ic_send
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.flowOf
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.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.LocalChatViewModel
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalProfileViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
import su.reya.coop.Room
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.nostr.Room
|
import su.reya.coop.formatAsGroupHeader
|
||||||
import su.reya.coop.nostr.formatAsGroupHeader
|
import su.reya.coop.humanReadable
|
||||||
import su.reya.coop.nostr.humanReadable
|
import su.reya.coop.rememberUiState
|
||||||
import su.reya.coop.nostr.roomId
|
import su.reya.coop.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.pictureFlow
|
|
||||||
import su.reya.coop.short
|
import su.reya.coop.short
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@@ -99,11 +96,14 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val chatViewModel = LocalChatViewModel.current
|
val chatViewModel = LocalChatViewModel.current
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
// Get current user
|
||||||
|
val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// Get chat room by ID
|
// Get chat room by ID
|
||||||
val chatRooms by chatViewModel.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 } } }
|
||||||
@@ -123,15 +123,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val roomState by (room as Room).rememberUiState(nostrViewModel, currentUser?.publicKey)
|
||||||
val displayName by remember(room) {
|
|
||||||
room?.nameFlow(profileViewModel) ?: flowOf("Loading...")
|
|
||||||
}.collectAsStateWithLifecycle("Loading...")
|
|
||||||
|
|
||||||
val picture by remember(room) {
|
|
||||||
room?.pictureFlow(profileViewModel) ?: flowOf(null)
|
|
||||||
}.collectAsStateWithLifecycle(null)
|
|
||||||
|
|
||||||
var text by remember { mutableStateOf("") }
|
var text by remember { mutableStateOf("") }
|
||||||
var loading by remember { mutableStateOf(true) }
|
var loading by remember { mutableStateOf(true) }
|
||||||
var newOtherMessages by remember { mutableIntStateOf(0) }
|
var newOtherMessages by remember { mutableIntStateOf(0) }
|
||||||
@@ -215,14 +207,14 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
|||||||
LoadingIndicator(modifier = Modifier.size(32.dp))
|
LoadingIndicator(modifier = Modifier.size(32.dp))
|
||||||
} else {
|
} else {
|
||||||
Avatar(
|
Avatar(
|
||||||
picture = picture,
|
picture = roomState.picture,
|
||||||
description = displayName,
|
description = roomState.name,
|
||||||
size = 32.dp,
|
size = 32.dp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.size(8.dp))
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = displayName,
|
text = roomState.name,
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -282,7 +274,10 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
|||||||
items = messagesInGroup,
|
items = messagesInGroup,
|
||||||
key = { it.id()?.toBech32() ?: it.hashCode() }
|
key = { it.id()?.toBech32() ?: it.hashCode() }
|
||||||
) {
|
) {
|
||||||
ChatMessage(it)
|
ChatMessage(
|
||||||
|
rumor = it,
|
||||||
|
isMine = currentUser?.publicKey == it.author()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
DateSeparator(dateHeader)
|
DateSeparator(dateHeader)
|
||||||
@@ -377,28 +372,24 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
|||||||
fun ScreenerCard(room: Room) {
|
fun ScreenerCard(room: Room) {
|
||||||
val pubkey = room.members.firstOrNull() ?: return
|
val pubkey = room.members.firstOrNull() ?: return
|
||||||
|
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
var isContact by remember { mutableStateOf(false) }
|
var isContact by remember { mutableStateOf(false) }
|
||||||
var mutualContacts by remember { mutableStateOf<Set<PublicKey>>(emptySet()) }
|
var mutualContacts by remember { mutableStateOf<Set<PublicKey>>(emptySet()) }
|
||||||
var lastActivity by remember { mutableStateOf<Timestamp?>(null) }
|
var lastActivity by remember { mutableStateOf<Timestamp?>(null) }
|
||||||
|
|
||||||
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) }
|
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
|
||||||
val metadata by metadataFlow.collectAsStateWithLifecycle()
|
val profile by profileFlow.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
|
||||||
val displayName = profile?.displayName ?: profile?.name ?: "No name"
|
|
||||||
val picture = profile?.picture
|
|
||||||
|
|
||||||
LaunchedEffect(pubkey) {
|
LaunchedEffect(pubkey) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
// Check contact
|
// Check contact
|
||||||
profileViewModel.verifyContact(pubkey).let { isContact = it }
|
nostrViewModel.verifyContact(pubkey).let { isContact = it }
|
||||||
// Get mutual contacts
|
// Get mutual contacts
|
||||||
profileViewModel.mutualContacts(pubkey).let { mutualContacts = it }
|
nostrViewModel.mutualContacts(pubkey).let { mutualContacts = it }
|
||||||
// Get the last activity
|
// Get the last activity
|
||||||
profileViewModel.verifyActivity(pubkey)?.let { lastActivity = it }
|
nostrViewModel.verifyActivity(pubkey)?.let { lastActivity = it }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,7 +406,7 @@ fun ScreenerCard(room: Room) {
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Avatar(
|
Avatar(
|
||||||
picture = picture,
|
picture = profile?.picture,
|
||||||
description = "Profile picture",
|
description = "Profile picture",
|
||||||
modifier = Modifier.size(120.dp),
|
modifier = Modifier.size(120.dp),
|
||||||
shape = MaterialShapes.Cookie12Sided.toShape(),
|
shape = MaterialShapes.Cookie12Sided.toShape(),
|
||||||
@@ -425,7 +416,7 @@ fun ScreenerCard(room: Room) {
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = displayName,
|
text = profile?.name ?: "No name",
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||||
fontFamily = getExpressiveFontFamily()
|
fontFamily = getExpressiveFontFamily()
|
||||||
@@ -514,13 +505,9 @@ fun DateSeparator(date: String) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatMessage(
|
fun ChatMessage(
|
||||||
rumor: UnsignedEvent
|
rumor: UnsignedEvent,
|
||||||
|
isMine: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val accountViewModel = LocalAccountViewModel.current
|
|
||||||
val chatViewModel = LocalChatViewModel.current
|
|
||||||
val currentUser = accountViewModel.nostr.signer.currentUser
|
|
||||||
val isMine = rumor.author() == currentUser
|
|
||||||
|
|
||||||
val bubbleShape = if (isMine) {
|
val bubbleShape = if (isMine) {
|
||||||
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 4.dp)
|
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 4.dp)
|
||||||
} else {
|
} else {
|
||||||
@@ -546,17 +533,7 @@ fun ChatMessage(
|
|||||||
color = containerColor,
|
color = containerColor,
|
||||||
contentColor = contentColor,
|
contentColor = contentColor,
|
||||||
shape = bubbleShape,
|
shape = bubbleShape,
|
||||||
modifier = Modifier
|
modifier = Modifier.widthIn(max = 280.dp)
|
||||||
.widthIn(max = 280.dp)
|
|
||||||
.clickable(
|
|
||||||
onClick = {
|
|
||||||
val id = rumor.id()
|
|
||||||
if (id != null) {
|
|
||||||
val sent = chatViewModel.isMessageSent(id)
|
|
||||||
println("Sent: $sent")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = rumor.content(),
|
text = rumor.content(),
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ import org.jetbrains.compose.resources.painterResource
|
|||||||
import rust.nostr.sdk.Nip05Address
|
import rust.nostr.sdk.Nip05Address
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalProfileViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
@@ -66,9 +66,9 @@ import su.reya.coop.short
|
|||||||
fun ContactListScreen() {
|
fun ContactListScreen() {
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
|
|
||||||
val contactList by profileViewModel.contactList.collectAsStateWithLifecycle()
|
val contactList by nostrViewModel.contactList.collectAsStateWithLifecycle()
|
||||||
var openAddContactDialog by remember { mutableStateOf(false) }
|
var openAddContactDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -180,7 +180,7 @@ fun ContactListScreen() {
|
|||||||
@Composable
|
@Composable
|
||||||
fun AddContactDialog(onDismissRequest: () -> Unit) {
|
fun AddContactDialog(onDismissRequest: () -> Unit) {
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
var contact by remember { mutableStateOf("") }
|
var contact by remember { mutableStateOf("") }
|
||||||
var isError by remember { mutableStateOf(false) }
|
var isError by remember { mutableStateOf(false) }
|
||||||
@@ -225,7 +225,7 @@ fun AddContactDialog(onDismissRequest: () -> Unit) {
|
|||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val success = profileViewModel.addContact(contact)
|
val success = nostrViewModel.addContact(contact)
|
||||||
if (success) onDismissRequest()
|
if (success) onDismissRequest()
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
@@ -283,32 +283,28 @@ fun ContactListItem(
|
|||||||
total: Int = 0,
|
total: Int = 0,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) }
|
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
|
||||||
val metadata by metadataFlow.collectAsState(initial = null)
|
val profile by profileFlow.collectAsState(initial = null)
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
|
||||||
val displayName = profile?.name ?: profile?.displayName ?: pubkey.short()
|
|
||||||
val picture = profile?.picture
|
|
||||||
|
|
||||||
SegmentedListItem(
|
SegmentedListItem(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = { profileViewModel.removeContact(pubkey) },
|
onLongClick = { nostrViewModel.removeContact(pubkey) },
|
||||||
shapes = ListItemDefaults.segmentedShapes(
|
shapes = ListItemDefaults.segmentedShapes(
|
||||||
index = index,
|
index = index,
|
||||||
count = total
|
count = total
|
||||||
),
|
),
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
Avatar(
|
Avatar(
|
||||||
picture = picture,
|
picture = profile?.picture,
|
||||||
description = displayName,
|
description = profile?.name,
|
||||||
size = 36.dp
|
size = 36.dp
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
supportingContent = { Text(text = pubkey.short()) },
|
supportingContent = { Text(text = pubkey.short()) },
|
||||||
content = {
|
content = {
|
||||||
Text(
|
Text(
|
||||||
text = displayName,
|
text = profile?.name ?: "No name",
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ import androidx.compose.material3.rememberTooltipState
|
|||||||
import androidx.compose.material3.toShape
|
import androidx.compose.material3.toShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -87,27 +86,22 @@ import coop.composeapp.generated.resources.ic_new_chat
|
|||||||
import coop.composeapp.generated.resources.ic_qr
|
import coop.composeapp.generated.resources.ic_qr
|
||||||
import coop.composeapp.generated.resources.ic_request
|
import coop.composeapp.generated.resources.ic_request
|
||||||
import coop.composeapp.generated.resources.ic_scanner
|
import coop.composeapp.generated.resources.ic_scanner
|
||||||
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.LocalAuthViewModel
|
||||||
import su.reya.coop.LocalAppViewModel
|
|
||||||
import su.reya.coop.LocalChatViewModel
|
import su.reya.coop.LocalChatViewModel
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalProfileViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
import su.reya.coop.LocalRelayViewModel
|
|
||||||
import su.reya.coop.LocalScanResult
|
import su.reya.coop.LocalScanResult
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
import su.reya.coop.Room
|
||||||
|
import su.reya.coop.RoomKind
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.nostr.Room
|
import su.reya.coop.ago
|
||||||
import su.reya.coop.nostr.RoomKind
|
import su.reya.coop.rememberUiState
|
||||||
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.pictureFlow
|
|
||||||
import su.reya.coop.short
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -117,27 +111,24 @@ fun HomeScreen() {
|
|||||||
val qrScanResult = LocalScanResult.current
|
val qrScanResult = LocalScanResult.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val clipboardManager = LocalClipboard.current
|
val clipboardManager = LocalClipboard.current
|
||||||
val appViewModel = LocalAppViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val accountViewModel = LocalAccountViewModel.current
|
|
||||||
val chatViewModel = LocalChatViewModel.current
|
val chatViewModel = LocalChatViewModel.current
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
val authViewModel = LocalAuthViewModel.current
|
||||||
val relayViewModel = LocalRelayViewModel.current
|
|
||||||
|
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val sheetState = rememberModalBottomSheetState(true)
|
val sheetState = rememberModalBottomSheetState(true)
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val pullToRefreshState = rememberPullToRefreshState()
|
val pullToRefreshState = rememberPullToRefreshState()
|
||||||
|
|
||||||
val currentUser = accountViewModel.nostr.signer.currentUser ?: return
|
val userProfile by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
|
||||||
val currentUserProfile = profileViewModel.getMetadata(currentUser)
|
|
||||||
|
|
||||||
val userProfile by currentUserProfile.collectAsStateWithLifecycle()
|
|
||||||
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
|
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
|
||||||
val isRelayListEmpty by relayViewModel.isRelayListEmpty.collectAsStateWithLifecycle()
|
|
||||||
|
val isRelayListEmpty by nostrViewModel.isRelayListEmpty.collectAsStateWithLifecycle()
|
||||||
val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle()
|
val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle()
|
||||||
val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
|
val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
|
||||||
val isBannerDismissed by appViewModel.isNotificationBannerDismissed.collectAsState()
|
|
||||||
|
val authState by authViewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val isBannerDismissed = authState.isNotificationBannerDismissed
|
||||||
|
|
||||||
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
|
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
|
||||||
var showBottomSheet by remember { mutableStateOf(false) }
|
var showBottomSheet by remember { mutableStateOf(false) }
|
||||||
@@ -165,7 +156,7 @@ fun HomeScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
chatViewModel.getChatRooms()
|
chatViewModel.refreshChatRooms()
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(qrScanResult.content) {
|
LaunchedEffect(qrScanResult.content) {
|
||||||
@@ -218,8 +209,8 @@ fun HomeScreen() {
|
|||||||
// User
|
// User
|
||||||
IconButton(onClick = { showBottomSheet = true }) {
|
IconButton(onClick = { showBottomSheet = true }) {
|
||||||
Avatar(
|
Avatar(
|
||||||
picture = userProfile?.asRecord()?.picture,
|
picture = userProfile?.picture,
|
||||||
description = userProfile?.asRecord()?.displayName,
|
description = userProfile?.name ?: "No name",
|
||||||
size = 32.dp,
|
size = 32.dp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -289,7 +280,7 @@ fun HomeScreen() {
|
|||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = { appViewModel.dismissNotificationBanner() },
|
onClick = { authViewModel.dismissNotificationBanner() },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
) {
|
) {
|
||||||
Text(text = "Maybe later")
|
Text(text = "Maybe later")
|
||||||
@@ -400,14 +391,6 @@ fun HomeScreen() {
|
|||||||
onDismissRequest = { showBottomSheet = false },
|
onDismissRequest = { showBottomSheet = false },
|
||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
) {
|
) {
|
||||||
val pubkey = accountViewModel.nostr.signer.currentUser
|
|
||||||
val shortPubkey = pubkey?.short() ?: "Not available"
|
|
||||||
|
|
||||||
val userName =
|
|
||||||
userProfile?.asRecord()?.displayName
|
|
||||||
?: userProfile?.asRecord()?.name
|
|
||||||
?: "No name"
|
|
||||||
|
|
||||||
val dismissAndRun: (suspend () -> Unit) -> Unit = { action ->
|
val dismissAndRun: (suspend () -> Unit) -> Unit = { action ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
sheetState.hide()
|
sheetState.hide()
|
||||||
@@ -432,8 +415,8 @@ fun HomeScreen() {
|
|||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Avatar(
|
Avatar(
|
||||||
picture = userProfile?.asRecord()?.picture,
|
picture = userProfile?.picture,
|
||||||
description = userProfile?.asRecord()?.displayName,
|
description = userProfile?.name ?: "No name",
|
||||||
shape = MaterialShapes.Cookie9Sided.toShape(),
|
shape = MaterialShapes.Cookie9Sided.toShape(),
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
@@ -443,7 +426,7 @@ fun HomeScreen() {
|
|||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = userName,
|
text = userProfile?.name ?: "No name",
|
||||||
style = MaterialTheme.typography.titleLargeEmphasized,
|
style = MaterialTheme.typography.titleLargeEmphasized,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -454,16 +437,15 @@ fun HomeScreen() {
|
|||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
pubkey?.let {
|
userProfile?.publicKey?.let {
|
||||||
val bech32 = it.toBech32()
|
val bech32 = it.toBech32()
|
||||||
val data =
|
val data = ClipData.newPlainText(bech32, bech32)
|
||||||
ClipData.newPlainText(bech32, bech32)
|
|
||||||
clipboardManager.setClipEntry(ClipEntry(data))
|
clipboardManager.setClipEntry(ClipEntry(data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Text(text = shortPubkey)
|
Text(text = userProfile?.shortPublicKey ?: "Unknown")
|
||||||
}
|
}
|
||||||
FilledIconButton(
|
FilledIconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -487,7 +469,7 @@ fun HomeScreen() {
|
|||||||
// Show the relay setup dialog if the msg relay list is empty
|
// Show the relay setup dialog if the msg relay list is empty
|
||||||
if (isRelayListEmpty) {
|
if (isRelayListEmpty) {
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = { relayViewModel.dismissRelayWarning() },
|
onDismissRequest = { nostrViewModel.dismissRelayWarning() },
|
||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
) {
|
) {
|
||||||
@@ -594,7 +576,7 @@ fun HomeScreen() {
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
isBusy = true
|
isBusy = true
|
||||||
try {
|
try {
|
||||||
relayViewModel.refetchMsgRelays(currentUser)
|
nostrViewModel.refetchMsgRelays()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
snackbarHostState.showSnackbar("Failed to refresh metadata: ${e.message}")
|
snackbarHostState.showSnackbar("Failed to refresh metadata: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -614,7 +596,7 @@ fun HomeScreen() {
|
|||||||
enabled = !isBusy,
|
enabled = !isBusy,
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
relayViewModel.useDefaultMsgRelayList()
|
nostrViewModel.useDefaultMsgRelayList()
|
||||||
sheetState.hide()
|
sheetState.hide()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -637,34 +619,29 @@ fun HomeScreen() {
|
|||||||
@Composable
|
@Composable
|
||||||
fun NewRequests(requests: List<Room>) {
|
fun NewRequests(requests: List<Room>) {
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
|
|
||||||
val total = requests.size
|
val total = requests.size
|
||||||
val firstRoom = requests.getOrNull(0)
|
val firstRoom = requests.getOrNull(0)
|
||||||
val secondRoom = requests.getOrNull(1)
|
val secondRoom = requests.getOrNull(1)
|
||||||
|
|
||||||
val firstName by remember(firstRoom) {
|
val firstRoomState by (firstRoom as Room).rememberUiState(nostrViewModel)
|
||||||
firstRoom?.nameFlow(profileViewModel) ?: flowOf("")
|
val secondRoomState by (secondRoom as Room).rememberUiState(nostrViewModel)
|
||||||
}.collectAsStateWithLifecycle("Loading...")
|
|
||||||
|
|
||||||
val secondName by remember(secondRoom) {
|
|
||||||
secondRoom?.nameFlow(profileViewModel) ?: flowOf("")
|
|
||||||
}.collectAsStateWithLifecycle("")
|
|
||||||
|
|
||||||
val supportingText = when {
|
val supportingText = when {
|
||||||
total == 1 && firstRoom != null -> {
|
total == 1 -> {
|
||||||
val message = firstRoom.lastMessage ?: ""
|
val message = firstRoom.lastMessage ?: ""
|
||||||
"$firstName: $message"
|
"${firstRoomState.name}: $message"
|
||||||
}
|
}
|
||||||
|
|
||||||
total == 2 -> {
|
total == 2 -> {
|
||||||
"$firstName and $secondName"
|
"${firstRoomState.name} and ${secondRoomState.name}"
|
||||||
}
|
}
|
||||||
|
|
||||||
total > 2 -> {
|
total > 2 -> {
|
||||||
val othersCount = total - 2
|
val othersCount = total - 2
|
||||||
val othersText = if (othersCount == 1) "1 other" else "$othersCount others"
|
val othersText = if (othersCount == 1) "1 other" else "$othersCount others"
|
||||||
"$firstName, $secondName and $othersText"
|
"${firstRoomState.name}, ${secondRoomState.name} and $othersText"
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> ""
|
else -> ""
|
||||||
@@ -720,14 +697,13 @@ fun NewRequests(requests: List<Room>) {
|
|||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatRoom(room: Room, onClick: () -> Unit) {
|
fun ChatRoom(room: Room, onClick: () -> Unit) {
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val displayName by remember(room) { room.nameFlow(profileViewModel) }.collectAsStateWithLifecycle("Loading...")
|
val roomState by room.rememberUiState(nostrViewModel)
|
||||||
val picture by remember(room) { room.pictureFlow(profileViewModel) }.collectAsStateWithLifecycle(null)
|
|
||||||
|
|
||||||
ListItem(
|
ListItem(
|
||||||
modifier = Modifier.clickable(onClick = onClick),
|
modifier = Modifier.clickable(onClick = onClick),
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
Avatar(picture = picture, description = displayName)
|
Avatar(picture = roomState.picture, description = roomState.picture)
|
||||||
},
|
},
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
Row(
|
Row(
|
||||||
@@ -735,7 +711,7 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
|
|||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = displayName,
|
text = roomState.name,
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
@@ -768,7 +744,9 @@ fun BottomMenuList(
|
|||||||
onDismiss: (suspend () -> Unit) -> Unit
|
onDismiss: (suspend () -> Unit) -> Unit
|
||||||
) {
|
) {
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val accountViewModel = LocalAccountViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
|
val chatViewModel = LocalChatViewModel.current
|
||||||
|
val authViewModel = LocalAuthViewModel.current
|
||||||
|
|
||||||
val defaultMenuList = listOf(
|
val defaultMenuList = listOf(
|
||||||
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
|
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
|
||||||
@@ -798,7 +776,14 @@ fun BottomMenuList(
|
|||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.size(16.dp))
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = { onDismiss { accountViewModel.logout() } },
|
onClick = {
|
||||||
|
onDismiss {
|
||||||
|
authViewModel.logout(onLogout = {
|
||||||
|
nostrViewModel.resetInternalState()
|
||||||
|
chatViewModel.resetInternalState()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
colors = ButtonDefaults.filledTonalButtonColors(
|
colors = ButtonDefaults.filledTonalButtonColors(
|
||||||
containerColor = MaterialTheme.colorScheme.error,
|
containerColor = MaterialTheme.colorScheme.error,
|
||||||
contentColor = MaterialTheme.colorScheme.onError
|
contentColor = MaterialTheme.colorScheme.onError
|
||||||
|
|||||||
@@ -58,16 +58,14 @@ 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.LocalAuthViewModel
|
||||||
import su.reya.coop.LocalAppViewModel
|
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalProfileViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
import su.reya.coop.LocalScanResult
|
import su.reya.coop.LocalScanResult
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
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.short
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -76,25 +74,20 @@ fun ImportScreen() {
|
|||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val qrScanResult = LocalScanResult.current
|
val qrScanResult = LocalScanResult.current
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val appViewModel = LocalAppViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val accountViewModel = LocalAccountViewModel.current
|
val authViewModel = LocalAuthViewModel.current
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val isBusy by appViewModel.isBusy.collectAsStateWithLifecycle(false)
|
val authState by authViewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val isBusy = authState.isBusy
|
||||||
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
|
val profile by remember(pubkey) {
|
||||||
val metadata by remember(pubkey) {
|
pubkey?.let(nostrViewModel::getMetadata) ?: flowOf(null)
|
||||||
pubkey?.let(profileViewModel::getMetadata) ?: flowOf(null)
|
|
||||||
}.collectAsStateWithLifecycle(null)
|
}.collectAsStateWithLifecycle(null)
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
|
||||||
val displayName = profile?.displayName ?: profile?.name ?: pubkey?.short() ?: "Unknown"
|
|
||||||
val picture = profile?.picture
|
|
||||||
|
|
||||||
LaunchedEffect(qrScanResult.content) {
|
LaunchedEffect(qrScanResult.content) {
|
||||||
qrScanResult.content?.let { result ->
|
qrScanResult.content?.let { result ->
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -168,7 +161,7 @@ fun ImportScreen() {
|
|||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Avatar(
|
Avatar(
|
||||||
picture = picture,
|
picture = profile?.picture,
|
||||||
description = "Profile picture",
|
description = "Profile picture",
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
shape = MaterialShapes.Cookie9Sided.toShape(),
|
shape = MaterialShapes.Cookie9Sided.toShape(),
|
||||||
@@ -176,7 +169,7 @@ fun ImportScreen() {
|
|||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.size(8.dp))
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = displayName,
|
text = profile?.name ?: "",
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||||
fontFamily = getExpressiveFontFamily()
|
fontFamily = getExpressiveFontFamily()
|
||||||
@@ -250,10 +243,10 @@ fun ImportScreen() {
|
|||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (pubkey == null) {
|
if (pubkey == null) {
|
||||||
accountViewModel.verifyIdentity(secret).let { pubkey = it }
|
authViewModel.verifyIdentity(secret).let { pubkey = it }
|
||||||
} else {
|
} else {
|
||||||
// Import the identity
|
// Import the identity
|
||||||
accountViewModel.importIdentity(secret)
|
authViewModel.importIdentity(secret)
|
||||||
// Navigate to the home screen
|
// Navigate to the home screen
|
||||||
navigator.navigate(Screen.Home)
|
navigator.navigate(Screen.Home)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,22 +13,24 @@ import androidx.compose.material3.SnackbarHost
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
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 io.github.alexzhirkevich.qrose.rememberQrCodePainter
|
import io.github.alexzhirkevich.qrose.rememberQrCodePainter
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import su.reya.coop.LocalAccountViewModel
|
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
|
import su.reya.coop.LocalNostrViewModel
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MyQrScreen() {
|
fun MyQrScreen() {
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val accountViewModel = LocalAccountViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val currentUser = accountViewModel.nostr.signer.currentUser ?: return
|
val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
@@ -59,7 +61,7 @@ fun MyQrScreen() {
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Image(
|
Image(
|
||||||
painter = rememberQrCodePainter(currentUser.toBech32()),
|
painter = rememberQrCodePainter(currentUser?.publicKey?.toBech32() ?: ""),
|
||||||
contentDescription = "My QR"
|
contentDescription = "My QR"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ 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.LocalChatViewModel
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalProfileViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
import su.reya.coop.LocalScanResult
|
import su.reya.coop.LocalScanResult
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
@@ -71,10 +71,10 @@ 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 nostrViewModel = LocalNostrViewModel.current
|
||||||
val chatViewModel = LocalChatViewModel.current
|
val chatViewModel = LocalChatViewModel.current
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
|
||||||
|
|
||||||
val contactList by profileViewModel.contactList.collectAsStateWithLifecycle()
|
val contactList by nostrViewModel.contactList.collectAsStateWithLifecycle()
|
||||||
var query by remember { mutableStateOf("") }
|
var query by remember { mutableStateOf("") }
|
||||||
|
|
||||||
val createGroup = remember { mutableStateOf(false) }
|
val createGroup = remember { mutableStateOf(false) }
|
||||||
@@ -96,12 +96,12 @@ fun NewChatScreen() {
|
|||||||
selectedReceivers.add(pubkey)
|
selectedReceivers.add(pubkey)
|
||||||
}
|
}
|
||||||
} else if (query.contains("@")) {
|
} else if (query.contains("@")) {
|
||||||
val pubkey = profileViewModel.searchByAddress(query)
|
val pubkey = nostrViewModel.searchByAddress(query)
|
||||||
if (pubkey != null) {
|
if (pubkey != null) {
|
||||||
selectedReceivers.add(pubkey)
|
selectedReceivers.add(pubkey)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val results = profileViewModel.searchByNostr(query)
|
val results = nostrViewModel.searchByNostr(query)
|
||||||
searchResults.clear()
|
searchResults.clear()
|
||||||
searchResults.addAll(results)
|
searchResults.addAll(results)
|
||||||
}
|
}
|
||||||
@@ -290,13 +290,9 @@ fun ReceiverChip(
|
|||||||
pubkey: PublicKey,
|
pubkey: PublicKey,
|
||||||
onRemove: () -> Unit
|
onRemove: () -> Unit
|
||||||
) {
|
) {
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) }
|
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
|
||||||
val metadata by metadataFlow.collectAsState(initial = null)
|
val profile by profileFlow.collectAsState(initial = null)
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
|
||||||
val displayName = profile?.name ?: profile?.displayName ?: pubkey.short()
|
|
||||||
val picture = profile?.picture
|
|
||||||
|
|
||||||
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
|
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
|
||||||
InputChip(
|
InputChip(
|
||||||
@@ -304,7 +300,7 @@ fun ReceiverChip(
|
|||||||
onClick = onRemove,
|
onClick = onRemove,
|
||||||
label = {
|
label = {
|
||||||
Text(
|
Text(
|
||||||
text = displayName,
|
text = profile?.name ?: "No name",
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold
|
||||||
)
|
)
|
||||||
@@ -312,8 +308,8 @@ fun ReceiverChip(
|
|||||||
},
|
},
|
||||||
avatar = {
|
avatar = {
|
||||||
Avatar(
|
Avatar(
|
||||||
picture = picture,
|
picture = profile?.picture,
|
||||||
description = displayName,
|
description = profile?.name ?: "No name",
|
||||||
size = 24.dp
|
size = 24.dp
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -376,13 +372,9 @@ fun ContactListItem(
|
|||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit
|
onLongClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) }
|
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
|
||||||
val metadata by metadataFlow.collectAsState(initial = null)
|
val profile by profileFlow.collectAsState(initial = null)
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
|
||||||
val displayName = profile?.name ?: profile?.displayName ?: pubkey.short()
|
|
||||||
val picture = profile?.picture
|
|
||||||
|
|
||||||
SegmentedListItem(
|
SegmentedListItem(
|
||||||
selected = isSelected,
|
selected = isSelected,
|
||||||
@@ -394,15 +386,15 @@ fun ContactListItem(
|
|||||||
),
|
),
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
Avatar(
|
Avatar(
|
||||||
picture = picture,
|
picture = profile?.picture,
|
||||||
description = displayName,
|
description = profile?.name ?: "",
|
||||||
size = 36.dp
|
size = 36.dp
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
supportingContent = { Text(text = pubkey.short()) },
|
supportingContent = { Text(text = pubkey.short()) },
|
||||||
content = {
|
content = {
|
||||||
Text(
|
Text(
|
||||||
text = displayName,
|
text = profile?.name ?: "",
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,21 +5,19 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import su.reya.coop.LocalAccountViewModel
|
import su.reya.coop.LocalAuthViewModel
|
||||||
import su.reya.coop.LocalAppViewModel
|
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
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 appViewModel = LocalAppViewModel.current
|
val authViewModel = LocalAuthViewModel.current
|
||||||
val accountViewModel = LocalAccountViewModel.current
|
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val isBusy by appViewModel.isBusy.collectAsStateWithLifecycle(false)
|
|
||||||
|
val authState by authViewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val isBusy = authState.isBusy
|
||||||
|
|
||||||
ProfileEditor(
|
ProfileEditor(
|
||||||
title = "Create a new identity",
|
title = "Create a new identity",
|
||||||
@@ -28,7 +26,7 @@ fun NewIdentityScreen() {
|
|||||||
onBack = { navigator.goBack() },
|
onBack = { navigator.goBack() },
|
||||||
onConfirm = { name, bio, bytes, type ->
|
onConfirm = { name, bio, bytes, type ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
accountViewModel.createIdentity(name, bio, bytes, type)
|
authViewModel.createIdentity(name, bio, bytes, type)
|
||||||
navigator.navigate(Screen.Home)
|
navigator.navigate(Screen.Home)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ 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.LocalAuthViewModel
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
@@ -57,7 +57,7 @@ fun OnboardingScreen() {
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val accountViewModel = LocalAccountViewModel.current
|
val authViewModel = LocalAuthViewModel.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val logoPainter = painterResource(Res.drawable.coop)
|
val logoPainter = painterResource(Res.drawable.coop)
|
||||||
@@ -158,9 +158,9 @@ fun OnboardingScreen() {
|
|||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (accountViewModel.isExternalSignerAvailable()) {
|
if (authViewModel.isExternalSignerAvailable()) {
|
||||||
try {
|
try {
|
||||||
accountViewModel.connectExternalSigner()
|
authViewModel.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) }
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ 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.LocalChatViewModel
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalProfileViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
@@ -56,28 +56,27 @@ import su.reya.coop.short
|
|||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ProfileScreen(pubkey: String) {
|
fun ProfileScreen(pubkey: String) {
|
||||||
|
val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
|
||||||
|
|
||||||
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 nostrViewModel = LocalNostrViewModel.current
|
||||||
val chatViewModel = LocalChatViewModel.current
|
val chatViewModel = LocalChatViewModel.current
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
|
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
|
||||||
|
val profile by profileFlow.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) }
|
val metadata = profile?.metadata?.asRecord()
|
||||||
val metadata by metadataFlow.collectAsStateWithLifecycle()
|
val nip05 = metadata?.nip05 ?: pubkey.short()
|
||||||
|
val picture = metadata?.picture
|
||||||
val profile = metadata?.asRecord()
|
|
||||||
val displayName = profile?.displayName ?: profile?.name ?: "No name"
|
|
||||||
val nip05 = profile?.nip05 ?: pubkey.short()
|
|
||||||
val picture = profile?.picture
|
|
||||||
|
|
||||||
val details = remember(profile) {
|
val details = remember(profile) {
|
||||||
listOf(
|
listOf(
|
||||||
"Username:" to (profile?.name ?: "None"),
|
"Username:" to (metadata?.name ?: "None"),
|
||||||
"Website:" to (profile?.website ?: "None"),
|
"Website:" to (metadata?.website ?: "None"),
|
||||||
"₿ Lightning Address:" to (profile?.lud16 ?: "None"),
|
"₿ Lightning Address:" to (metadata?.lud16 ?: "None"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +134,7 @@ fun ProfileScreen(pubkey: String) {
|
|||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = displayName,
|
text = profile?.name ?: "No name",
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||||
fontFamily = getExpressiveFontFamily()
|
fontFamily = getExpressiveFontFamily()
|
||||||
@@ -161,7 +160,8 @@ fun ProfileScreen(pubkey: String) {
|
|||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
val roomId = chatViewModel.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.LocalRelayViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@@ -74,7 +74,7 @@ import su.reya.coop.LocalSnackbarHostState
|
|||||||
fun RelayScreen() {
|
fun RelayScreen() {
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val relayViewModel = LocalRelayViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val msgRelayList = remember { mutableStateListOf<RelayUrl>() }
|
val msgRelayList = remember { mutableStateListOf<RelayUrl>() }
|
||||||
@@ -96,8 +96,8 @@ fun RelayScreen() {
|
|||||||
var relayToDelete by remember { mutableStateOf<String?>(null) }
|
var relayToDelete by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
relayList.putAll(relayViewModel.currentUserRelayList())
|
relayList.putAll(nostrViewModel.currentUserRelayList())
|
||||||
msgRelayList.addAll(relayViewModel.currentUserMsgRelayList())
|
msgRelayList.addAll(nostrViewModel.currentUserMsgRelayList())
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -321,7 +321,7 @@ fun RelayScreen() {
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
relayViewModel.removeMsgRelay(relayToDelete!!)
|
nostrViewModel.removeMsgRelay(relayToDelete!!)
|
||||||
msgRelayList.removeIf { it.toString() == relayToDelete }
|
msgRelayList.removeIf { it.toString() == relayToDelete }
|
||||||
relayToDelete = null
|
relayToDelete = null
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -349,7 +349,7 @@ fun AddRelayDialog(
|
|||||||
onMsgRelayAdded: (newRelay: String) -> Unit,
|
onMsgRelayAdded: (newRelay: String) -> Unit,
|
||||||
onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit,
|
onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit,
|
||||||
) {
|
) {
|
||||||
val relayViewModel = LocalRelayViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -401,17 +401,17 @@ fun AddRelayDialog(
|
|||||||
if (!isError) {
|
if (!isError) {
|
||||||
when (selected) {
|
when (selected) {
|
||||||
"Messaging" -> {
|
"Messaging" -> {
|
||||||
relayViewModel.addMsgRelay(relayAddress)
|
nostrViewModel.addMsgRelay(relayAddress)
|
||||||
onMsgRelayAdded(relayAddress)
|
onMsgRelayAdded(relayAddress)
|
||||||
}
|
}
|
||||||
|
|
||||||
"Inbox" -> {
|
"Inbox" -> {
|
||||||
relayViewModel.addInboxRelay(relayAddress)
|
nostrViewModel.addInboxRelay(relayAddress)
|
||||||
onRelayAdded(relayAddress, RelayMetadata.WRITE)
|
onRelayAdded(relayAddress, RelayMetadata.WRITE)
|
||||||
}
|
}
|
||||||
|
|
||||||
"Outbox" -> {
|
"Outbox" -> {
|
||||||
relayViewModel.addOutboxRelay(relayAddress)
|
nostrViewModel.addOutboxRelay(relayAddress)
|
||||||
onRelayAdded(relayAddress, RelayMetadata.READ)
|
onRelayAdded(relayAddress, RelayMetadata.READ)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ import kotlinx.coroutines.launch
|
|||||||
import su.reya.coop.LocalChatViewModel
|
import su.reya.coop.LocalChatViewModel
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
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
|
||||||
|
|||||||
@@ -5,25 +5,19 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import su.reya.coop.LocalAccountViewModel
|
|
||||||
import su.reya.coop.LocalAppViewModel
|
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalProfileViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
import su.reya.coop.shared.ProfileEditor
|
import su.reya.coop.shared.ProfileEditor
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun UpdateProfileScreen() {
|
fun UpdateProfileScreen() {
|
||||||
val appViewModel = LocalAppViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val accountViewModel = LocalAccountViewModel.current
|
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val currentUser = accountViewModel.nostr.signer.currentUser ?: return
|
val isBusy by nostrViewModel.isBusy.collectAsStateWithLifecycle(false)
|
||||||
val metadata by profileViewModel.getMetadata(currentUser).collectAsStateWithLifecycle()
|
val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
|
||||||
val isBusy by appViewModel.isBusy.collectAsStateWithLifecycle(false)
|
val profile = currentUser?.metadata?.asRecord()
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
|
||||||
|
|
||||||
ProfileEditor(
|
ProfileEditor(
|
||||||
title = "Update profile",
|
title = "Update profile",
|
||||||
@@ -35,7 +29,7 @@ fun UpdateProfileScreen() {
|
|||||||
onBack = { navigator.goBack() },
|
onBack = { navigator.goBack() },
|
||||||
onConfirm = { name, bio, bytes, type ->
|
onConfirm = { name, bio, bytes, type ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
profileViewModel.updateProfile(name, bio, bytes, type)
|
nostrViewModel.updateProfile(name, bio, bytes, type)
|
||||||
navigator.goBack()
|
navigator.goBack()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
package su.reya.coop.shared
|
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.flow.flowOf
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import rust.nostr.sdk.PublicKey
|
|
||||||
import su.reya.coop.nostr.Room
|
|
||||||
import su.reya.coop.short
|
|
||||||
import su.reya.coop.viewmodels.ProfileViewModel
|
|
||||||
|
|
||||||
fun Room.nameFlow(
|
|
||||||
profileViewModel: ProfileViewModel,
|
|
||||||
currentUser: PublicKey? = null
|
|
||||||
): Flow<String> {
|
|
||||||
// Return early if there's a custom subject/room name
|
|
||||||
subject?.takeIf { it.isNotBlank() }?.let { return flowOf(it) }
|
|
||||||
|
|
||||||
val displayMembers = if (isGroup()) members.take(2) else members.take(1)
|
|
||||||
if (displayMembers.isEmpty()) return flowOf("Unknown")
|
|
||||||
|
|
||||||
return combine(displayMembers.map { profileViewModel.getMetadata(it) }) { metadataArray ->
|
|
||||||
val names = metadataArray.mapIndexed { i, metadata ->
|
|
||||||
val profile = metadata?.asRecord()
|
|
||||||
profile?.displayName?.takeIf { it.isNotBlank() }
|
|
||||||
?: profile?.name?.takeIf { it.isNotBlank() }
|
|
||||||
?: displayMembers[i].short()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isGroup()) {
|
|
||||||
val combined = names.joinToString(", ")
|
|
||||||
val extraCount = members.size - names.size
|
|
||||||
if (extraCount > 0) "$combined, +$extraCount" else combined
|
|
||||||
} else {
|
|
||||||
val name = names.first()
|
|
||||||
if (displayMembers.first() == currentUser) "$name (you)" else name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Room.pictureFlow(profileViewModel: ProfileViewModel): Flow<String?> {
|
|
||||||
val firstMember = members.firstOrNull() ?: return flowOf(null)
|
|
||||||
return profileViewModel.getMetadata(firstMember).map { it?.asRecord()?.picture }
|
|
||||||
}
|
|
||||||
@@ -10,14 +10,14 @@ androidx-espresso = "3.7.0"
|
|||||||
androidx-lifecycle = "2.10.0"
|
androidx-lifecycle = "2.10.0"
|
||||||
androidx-testExt = "1.3.0"
|
androidx-testExt = "1.3.0"
|
||||||
androidx-splashscreen = "1.2.0"
|
androidx-splashscreen = "1.2.0"
|
||||||
composeMultiplatform = "1.11.0"
|
composeMultiplatform = "1.11.1"
|
||||||
datastorePreferences = "1.2.1"
|
datastorePreferences = "1.2.1"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
kotlin = "2.3.21"
|
kotlin = "2.4.0"
|
||||||
kotlinx-serialization = "1.11.0"
|
kotlinx-serialization = "1.11.0"
|
||||||
material3 = "1.11.0-alpha07"
|
material3 = "1.11.0-alpha07"
|
||||||
multiplatform-nav3-ui = "1.1.1"
|
multiplatform-nav3-ui = "1.1.1"
|
||||||
ktor = "3.5.0"
|
ktor = "3.5.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
|
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlinMultiplatform)
|
alias(libs.plugins.kotlinMultiplatform)
|
||||||
alias(libs.plugins.androidLibrary)
|
alias(libs.plugins.androidLibrary)
|
||||||
|
alias(libs.plugins.composeMultiplatform)
|
||||||
|
alias(libs.plugins.composeCompiler)
|
||||||
kotlin("plugin.serialization") version libs.versions.kotlin.get()
|
kotlin("plugin.serialization") version libs.versions.kotlin.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,10 +33,10 @@ kotlin {
|
|||||||
implementation(libs.ktor.serialization.kotlinx.json)
|
implementation(libs.ktor.serialization.kotlinx.json)
|
||||||
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
||||||
implementation(libs.androidx.lifecycle.runtimeCompose)
|
implementation(libs.androidx.lifecycle.runtimeCompose)
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
|
||||||
implementation("su.reya:nostr-sdk-kmp:0.3.2")
|
implementation("su.reya:nostr-sdk-kmp:0.3.2")
|
||||||
implementation("com.squareup.okio:okio:3.16.2")
|
implementation("com.squareup.okio:okio:3.17.0")
|
||||||
}
|
}
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
implementation(libs.ktor.client.okhttp)
|
implementation(libs.ktor.client.okhttp)
|
||||||
|
|||||||
20
shared/src/commonMain/kotlin/su/reya/coop/Profile.kt
Normal file
20
shared/src/commonMain/kotlin/su/reya/coop/Profile.kt
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package su.reya.coop
|
||||||
|
|
||||||
|
import rust.nostr.sdk.Metadata
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
|
||||||
|
data class Profile(
|
||||||
|
val publicKey: PublicKey,
|
||||||
|
val metadata: Metadata
|
||||||
|
) {
|
||||||
|
private val record by lazy { metadata.asRecord() }
|
||||||
|
|
||||||
|
val name: String
|
||||||
|
get() = record.displayName ?: record.name ?: publicKey.short()
|
||||||
|
|
||||||
|
val picture: String?
|
||||||
|
get() = record.picture
|
||||||
|
|
||||||
|
val shortPublicKey: String
|
||||||
|
get() = publicKey.short()
|
||||||
|
}
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
package su.reya.coop.nostr
|
package su.reya.coop
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.minus
|
import kotlinx.datetime.minus
|
||||||
@@ -8,6 +15,7 @@ import kotlinx.datetime.toLocalDateTime
|
|||||||
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.viewmodel.NostrViewModel
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@@ -75,9 +83,60 @@ data class Room(
|
|||||||
return this.copy(lastMessage = message)
|
return this.copy(lastMessage = message)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isGroup(): Boolean {
|
fun isGroup(): Boolean = members.size > 1
|
||||||
return members.size > 1
|
}
|
||||||
|
|
||||||
|
data class RoomUiState(
|
||||||
|
val name: String = "Loading...",
|
||||||
|
val picture: String? = null,
|
||||||
|
val isGroup: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
fun Room.uiStateFlow(
|
||||||
|
nostrViewModel: NostrViewModel,
|
||||||
|
currentUser: PublicKey? = null
|
||||||
|
): Flow<RoomUiState> {
|
||||||
|
val displayMembers = if (isGroup()) members.take(2) else members.take(1)
|
||||||
|
|
||||||
|
if (!subject.isNullOrBlank()) {
|
||||||
|
return flowOf(RoomUiState(name = subject, isGroup = isGroup()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return combine(displayMembers.map { nostrViewModel.getMetadata(it) }) { profiles ->
|
||||||
|
val names = profiles.mapIndexed { i, profile -> profile?.name ?: displayMembers[i].short() }
|
||||||
|
|
||||||
|
val name = when {
|
||||||
|
isGroup() -> {
|
||||||
|
val combined = names.joinToString(", ")
|
||||||
|
val extra = members.size - names.size
|
||||||
|
if (extra > 0) "$combined, +$extra" else combined
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
val first = names.firstOrNull() ?: "Unknown"
|
||||||
|
if (displayMembers.firstOrNull() == currentUser) "$first (you)" else first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RoomUiState(
|
||||||
|
name = name,
|
||||||
|
picture = profiles.firstOrNull()?.picture,
|
||||||
|
isGroup = isGroup()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Room.rememberUiState(
|
||||||
|
viewModel: NostrViewModel,
|
||||||
|
currentUser: PublicKey? = null
|
||||||
|
): State<RoomUiState> {
|
||||||
|
return remember(this, currentUser) {
|
||||||
|
uiStateFlow(
|
||||||
|
viewModel,
|
||||||
|
currentUser
|
||||||
|
)
|
||||||
|
}.collectAsStateWithLifecycle(RoomUiState())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun UnsignedEvent.roomId(): Long {
|
fun UnsignedEvent.roomId(): Long {
|
||||||
@@ -24,6 +24,9 @@ import rust.nostr.sdk.Tag
|
|||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
import rust.nostr.sdk.nip17ExtractRelayList
|
import rust.nostr.sdk.nip17ExtractRelayList
|
||||||
import rust.nostr.sdk.nip59MakeGiftWrapAsync
|
import rust.nostr.sdk.nip59MakeGiftWrapAsync
|
||||||
|
import su.reya.coop.Room
|
||||||
|
import su.reya.coop.RoomKind
|
||||||
|
import su.reya.coop.roomId
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
data class MessageSyncState(
|
data class MessageSyncState(
|
||||||
@@ -47,7 +50,9 @@ class MessageManager(private val nostr: Nostr) {
|
|||||||
|
|
||||||
suspend fun getUserMessages(msgRelayList: Event) {
|
suspend fun getUserMessages(msgRelayList: Event) {
|
||||||
try {
|
try {
|
||||||
val author = signer.currentUser ?: throw IllegalStateException("User not signed in")
|
val author =
|
||||||
|
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
val relays = nip17ExtractRelayList(msgRelayList)
|
val relays = nip17ExtractRelayList(msgRelayList)
|
||||||
|
|
||||||
// Ensure relay connections
|
// Ensure relay connections
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ class Nostr {
|
|||||||
// Initialize configurations for nostr client
|
// Initialize configurations for nostr client
|
||||||
val lmdb = NostrDatabase.lmdb(dbPath)
|
val lmdb = NostrDatabase.lmdb(dbPath)
|
||||||
val gossip = NostrGossip.inMemory()
|
val gossip = NostrGossip.inMemory()
|
||||||
|
|
||||||
|
// Initialize the authenticator
|
||||||
val authenticator = SignerAuthenticator(signer)
|
val authenticator = SignerAuthenticator(signer)
|
||||||
val idleTimeout = Duration.parse("5m")
|
val idleTimeout = Duration.parse("5m")
|
||||||
|
|
||||||
@@ -136,7 +138,7 @@ class Nostr {
|
|||||||
|
|
||||||
fun isSignedByUser(event: Event): Boolean {
|
fun isSignedByUser(event: Event): Boolean {
|
||||||
return try {
|
return try {
|
||||||
signer.currentUser == event.author()
|
signer.publicKeyFlow.value == event.author()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("Failed to check if event is signed by user: ${e.message}")
|
println("Failed to check if event is signed by user: ${e.message}")
|
||||||
false
|
false
|
||||||
@@ -208,7 +210,7 @@ class Nostr {
|
|||||||
if (isSignedByUser(event = event)) {
|
if (isSignedByUser(event = event)) {
|
||||||
val pubkeys = event.tags().publicKeys()
|
val pubkeys = event.tags().publicKeys()
|
||||||
// Get mutual contacts
|
// Get mutual contacts
|
||||||
profiles.getMutualContacts(pubkeys)
|
profiles.syncMutualContacts(pubkeys)
|
||||||
// Emit contact list update
|
// Emit contact list update
|
||||||
onContactListUpdate(pubkeys)
|
onContactListUpdate(pubkeys)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ class ProfileManager(private val nostr: Nostr) {
|
|||||||
|
|
||||||
suspend fun getUserMetadata() {
|
suspend fun getUserMetadata() {
|
||||||
try {
|
try {
|
||||||
val author = signer.currentUser ?: throw IllegalStateException("User not signed in")
|
val author =
|
||||||
|
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
// Get the latest metadata event
|
// Get the latest metadata event
|
||||||
val metadataFilter =
|
val metadataFilter =
|
||||||
@@ -73,22 +74,13 @@ class ProfileManager(private val nostr: Nostr) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getMutualContacts(pubkeys: List<PublicKey>) {
|
suspend fun syncMutualContacts(pubkeys: List<PublicKey>) {
|
||||||
try {
|
try {
|
||||||
val kind = Kind.fromStd(KindStandard.CONTACT_LIST)
|
val kind = Kind.fromStd(KindStandard.CONTACT_LIST)
|
||||||
val filter = Filter().kind(kind).authors(pubkeys).limit(pubkeys.size.toULong())
|
val filter = Filter().kind(kind).authors(pubkeys).limit(pubkeys.size.toULong())
|
||||||
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
val relays = NostrManager.BOOTSTRAP_RELAYS.map { RelayUrl.parse(it) }
|
||||||
|
|
||||||
val target = mutableMapOf<RelayUrl, List<Filter>>()
|
client?.sync(filter, relays)
|
||||||
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) {
|
} catch (e: Exception) {
|
||||||
throw IllegalStateException("Failed to fetch mutual contacts: ${e.message}", e)
|
throw IllegalStateException("Failed to fetch mutual contacts: ${e.message}", e)
|
||||||
}
|
}
|
||||||
@@ -146,7 +138,8 @@ class ProfileManager(private val nostr: Nostr) {
|
|||||||
bio: String? = null,
|
bio: String? = null,
|
||||||
picture: String? = null
|
picture: String? = null
|
||||||
): Metadata {
|
): Metadata {
|
||||||
val currentUser = signer.currentUser ?: throw IllegalStateException("User not signed in")
|
val currentUser =
|
||||||
|
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord()
|
val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord()
|
||||||
|
|||||||
@@ -134,8 +134,9 @@ class RelayManager(private val nostr: Nostr) {
|
|||||||
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
||||||
val filter = Filter().kind(kind).author(publicKey).limit(1u)
|
val filter = Filter().kind(kind).author(publicKey).limit(1u)
|
||||||
val events = client?.database()?.query(filter)
|
val events = client?.database()?.query(filter)
|
||||||
|
val event = events?.first() ?: return emptyList()
|
||||||
|
|
||||||
return nip17ExtractRelayList(events?.toVec()?.firstOrNull() ?: return emptyList())
|
return nip17ExtractRelayList(event)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw IllegalStateException("Failed to get msg relays: ${e.message}", e)
|
throw IllegalStateException("Failed to get msg relays: ${e.message}", e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package su.reya.coop.nostr
|
package su.reya.coop.nostr
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
@@ -16,9 +18,8 @@ class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner {
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var signer: AsyncNostrSigner = initialSigner
|
private var signer: AsyncNostrSigner = initialSigner
|
||||||
|
|
||||||
@Volatile
|
private val _publicKeyFlow = MutableStateFlow<PublicKey?>(null)
|
||||||
var currentUser: PublicKey? = null
|
val publicKeyFlow = _publicKeyFlow.asStateFlow()
|
||||||
private set
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current signer.
|
* Get the current signer.
|
||||||
@@ -37,7 +38,7 @@ class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner {
|
|||||||
throw IllegalStateException("Failed to get public key from signer", e)
|
throw IllegalStateException("Failed to get public key from signer", e)
|
||||||
}
|
}
|
||||||
signer = newSigner
|
signer = newSigner
|
||||||
currentUser = pubkey
|
_publicKeyFlow.value = pubkey
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPublicKeyAsync(): PublicKey? {
|
override suspend fun getPublicKeyAsync(): PublicKey? {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package su.reya.coop.repository
|
||||||
|
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
|
|
||||||
|
object ErrorRepository {
|
||||||
|
private val _errors = Channel<String>(Channel.BUFFERED)
|
||||||
|
val errors = _errors.receiveAsFlow()
|
||||||
|
|
||||||
|
fun showError(message: String) {
|
||||||
|
_errors.trySend(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package su.reya.coop.repository
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import rust.nostr.sdk.AsyncNostrSigner
|
||||||
|
import su.reya.coop.blossom.BlossomClient
|
||||||
|
|
||||||
|
class MediaRepository {
|
||||||
|
private val httpClient = HttpClient {
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
prettyPrint = true
|
||||||
|
isLenient = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun blossomUpload(
|
||||||
|
signer: AsyncNostrSigner,
|
||||||
|
file: ByteArray,
|
||||||
|
contentType: String? = "image/jpeg"
|
||||||
|
): String? {
|
||||||
|
return try {
|
||||||
|
val blossom = BlossomClient(url = "https://blossom.band", client = httpClient)
|
||||||
|
val descriptor = blossom.upload(
|
||||||
|
file = file,
|
||||||
|
contentType = contentType,
|
||||||
|
signer = signer,
|
||||||
|
)
|
||||||
|
descriptor?.url
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Upload failed: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
package su.reya.coop.viewmodels
|
package su.reya.coop.viewmodel
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import rust.nostr.sdk.AsyncNostrSigner
|
import rust.nostr.sdk.AsyncNostrSigner
|
||||||
@@ -15,36 +15,56 @@ import su.reya.coop.nostr.ExternalSignerHandler
|
|||||||
import su.reya.coop.nostr.ExternalSignerProxy
|
import su.reya.coop.nostr.ExternalSignerProxy
|
||||||
import su.reya.coop.nostr.Nostr
|
import su.reya.coop.nostr.Nostr
|
||||||
import su.reya.coop.nostr.SignerPermissions
|
import su.reya.coop.nostr.SignerPermissions
|
||||||
|
import su.reya.coop.repository.MediaRepository
|
||||||
import su.reya.coop.storage.SecretStorage
|
import su.reya.coop.storage.SecretStorage
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
class AccountViewModel(
|
data class AuthState(
|
||||||
val nostr: Nostr,
|
val isBusy: Boolean = false,
|
||||||
|
val signerRequired: Boolean? = null,
|
||||||
|
val isNotificationBannerDismissed: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
class AuthViewModel(
|
||||||
|
private val nostr: Nostr,
|
||||||
private val secretStore: SecretStorage,
|
private val secretStore: SecretStorage,
|
||||||
private val appViewModel: AppViewModel,
|
|
||||||
private val externalSignerHandler: ExternalSignerHandler? = null,
|
private val externalSignerHandler: ExternalSignerHandler? = null,
|
||||||
) : ViewModel() {
|
) : BaseViewModel() {
|
||||||
private val _signerRequired = MutableStateFlow<Boolean?>(null)
|
private val mediaRepository = MediaRepository()
|
||||||
val signerRequired = _signerRequired.asStateFlow()
|
|
||||||
|
companion object {
|
||||||
|
private const val KEY_USER_SIGNER = "user_signer"
|
||||||
|
private const val KEY_APP_KEYS = "app_keys"
|
||||||
|
private const val KEY_BANNER_DISMISSED = "notification_banner_dismissed"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(AuthState())
|
||||||
|
val state = _state.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Skip the splash screen if a user is already logged in
|
// Check if the notification banner has been dismissed
|
||||||
if (nostr.signer.currentUser != null) {
|
checkNotificationBannerDismissedStatus()
|
||||||
_signerRequired.value = false
|
|
||||||
}
|
// Check local stored secret (secret key or bunker)
|
||||||
// Check local stored secret
|
|
||||||
login()
|
login()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun checkNotificationBannerDismissedStatus() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val dismissed = secretStore.get(KEY_BANNER_DISMISSED) == "true"
|
||||||
|
_state.update { it.copy(isNotificationBannerDismissed = dismissed) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun login() {
|
private fun login() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val secret = withTimeoutOrNull(3.seconds) {
|
val secret = withTimeoutOrNull(3.seconds) {
|
||||||
secretStore.get("user_signer")
|
secretStore.get(KEY_USER_SIGNER)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (secret == null) {
|
if (secret == null) {
|
||||||
_signerRequired.value = true
|
_state.update { it.copy(signerRequired = true) }
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,44 +72,59 @@ class AccountViewModel(
|
|||||||
val signer = createSigner(secret)
|
val signer = createSigner(secret)
|
||||||
nostr.setSigner(signer)
|
nostr.setSigner(signer)
|
||||||
}.onSuccess {
|
}.onSuccess {
|
||||||
_signerRequired.value = false
|
_state.update { it.copy(signerRequired = false) }
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
appViewModel.showError("Login failed: ${e.message}")
|
showError("Login failed: ${e.message}")
|
||||||
_signerRequired.value = true
|
_state.update { it.copy(signerRequired = true) }
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
appViewModel.showError("Login failed: ${e.message}")
|
showError("Login failed: ${e.message}")
|
||||||
_signerRequired.value = true
|
_state.update { it.copy(signerRequired = true) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun logout() {
|
fun logout(onLogout: () -> Unit = {}) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
appViewModel.setBusy(true)
|
_state.update { it.copy(isBusy = true) }
|
||||||
|
|
||||||
// Reset the nostr signer and prune the database
|
// Reset the nostr signer and prune the database
|
||||||
nostr.signer.switch(Keys.generate())
|
nostr.signer.switch(Keys.generate())
|
||||||
nostr.prune()
|
nostr.prune()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
appViewModel.showError("Logout encountered an error: ${e.message}")
|
showError("Logout encountered an error: ${e.message}")
|
||||||
} finally {
|
} finally {
|
||||||
// Clear credentials from persistent storage
|
// Clear credentials from persistent storage
|
||||||
secretStore.clear("user_signer")
|
secretStore.clear(KEY_USER_SIGNER)
|
||||||
secretStore.clear("notification_banner_dismissed")
|
secretStore.clear(KEY_BANNER_DISMISSED)
|
||||||
// Reset local states
|
|
||||||
appViewModel.setBusy(false)
|
// Call cleanup callback (e.g. to reset other ViewModels)
|
||||||
_signerRequired.value = true
|
onLogout()
|
||||||
|
|
||||||
|
_state.update { it.copy(isBusy = false, signerRequired = true) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getOrInitAppKeys(): Keys {
|
fun dismissNotificationBanner() {
|
||||||
val secret = secretStore.get("app_keys")
|
viewModelScope.launch {
|
||||||
if (secret != null) return Keys.parse(secret)
|
secretStore.set(KEY_BANNER_DISMISSED, "true")
|
||||||
|
_state.update { it.copy(isNotificationBannerDismissed = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getOrInitAppKeys(): Keys {
|
||||||
|
val secret = secretStore.get(KEY_APP_KEYS)
|
||||||
|
|
||||||
|
// If app keys are already stored, use them
|
||||||
|
if (secret != null) {
|
||||||
|
return Keys.parse(secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new app keys and save to the secret storage
|
||||||
val keys = Keys.generate()
|
val keys = Keys.generate()
|
||||||
secretStore.set("app_keys", keys.secretKey().toBech32())
|
secretStore.set(KEY_APP_KEYS, keys.secretKey().toBech32())
|
||||||
|
|
||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
@@ -97,6 +132,7 @@ class AccountViewModel(
|
|||||||
private suspend fun createSigner(secret: String): AsyncNostrSigner {
|
private suspend fun createSigner(secret: String): AsyncNostrSigner {
|
||||||
return when {
|
return when {
|
||||||
secret.startsWith("nsec1") -> Keys.parse(secret)
|
secret.startsWith("nsec1") -> Keys.parse(secret)
|
||||||
|
|
||||||
secret.startsWith("bunker://") -> {
|
secret.startsWith("bunker://") -> {
|
||||||
val appKeys = getOrInitAppKeys()
|
val appKeys = getOrInitAppKeys()
|
||||||
val bunker = NostrConnectUri.parse(secret)
|
val bunker = NostrConnectUri.parse(secret)
|
||||||
@@ -107,9 +143,12 @@ class AccountViewModel(
|
|||||||
secret.startsWith("nip55://") -> {
|
secret.startsWith("nip55://") -> {
|
||||||
val handler = externalSignerHandler
|
val handler = externalSignerHandler
|
||||||
?: throw IllegalStateException("External signer not available on this platform")
|
?: throw IllegalStateException("External signer not available on this platform")
|
||||||
|
|
||||||
|
// Format: nip55://packageName/hexPubkey
|
||||||
val parts = secret.removePrefix("nip55://").split("/", limit = 2)
|
val parts = secret.removePrefix("nip55://").split("/", limit = 2)
|
||||||
val packageName = parts[0]
|
val packageName = parts[0]
|
||||||
val pubkey = PublicKey.parse(parts[1])
|
val pubkey = PublicKey.parse(parts[1])
|
||||||
|
|
||||||
handler.setPackageName(packageName)
|
handler.setPackageName(packageName)
|
||||||
ExternalSignerProxy(handler, pubkey)
|
ExternalSignerProxy(handler, pubkey)
|
||||||
}
|
}
|
||||||
@@ -122,58 +161,34 @@ class AccountViewModel(
|
|||||||
try {
|
try {
|
||||||
val signer = createSigner(secret)
|
val signer = createSigner(secret)
|
||||||
if (secret.startsWith("bunker://")) {
|
if (secret.startsWith("bunker://")) {
|
||||||
appViewModel.showError("Please approve the connection.")
|
showError("Please approve the connection.")
|
||||||
}
|
}
|
||||||
return signer.getPublicKeyAsync()
|
return signer.getPublicKeyAsync()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
appViewModel.showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun importIdentity(secret: String) {
|
suspend fun importIdentity(secret: String) {
|
||||||
appViewModel.setBusy(true)
|
_state.update { it.copy(isBusy = true) }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val signer = createSigner(secret)
|
val signer = createSigner(secret)
|
||||||
|
// Update signer
|
||||||
nostr.setSigner(signer)
|
nostr.setSigner(signer)
|
||||||
secretStore.set("user_signer", secret)
|
// Persist the secret in the secret storage
|
||||||
_signerRequired.value = false
|
secretStore.set(KEY_USER_SIGNER, secret)
|
||||||
|
// Update local states
|
||||||
|
_state.update { it.copy(signerRequired = false, isBusy = false) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
appViewModel.showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
} finally {
|
_state.update { it.copy(isBusy = false) }
|
||||||
appViewModel.setBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun createIdentity(
|
|
||||||
name: String,
|
|
||||||
bio: String?,
|
|
||||||
picture: ByteArray?,
|
|
||||||
contentType: String? = "image/jpeg",
|
|
||||||
) {
|
|
||||||
appViewModel.setBusy(true)
|
|
||||||
|
|
||||||
val keys = Keys.generate()
|
|
||||||
val secret = keys.secretKey().toBech32()
|
|
||||||
|
|
||||||
try {
|
|
||||||
val avatarUrl = picture?.let { appViewModel.blossomUpload(it, contentType) }
|
|
||||||
nostr.profiles.createIdentity(keys = keys, name = name, bio, picture = avatarUrl)
|
|
||||||
// Set credentials in persistent storage
|
|
||||||
secretStore.set("user_signer", secret)
|
|
||||||
// Update local state
|
|
||||||
_signerRequired.value = false
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
} finally {
|
|
||||||
appViewModel.setBusy(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun connectExternalSigner() {
|
suspend fun connectExternalSigner() {
|
||||||
val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available")
|
val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available")
|
||||||
appViewModel.setBusy(true)
|
_state.update { it.copy(isBusy = true) }
|
||||||
try {
|
try {
|
||||||
val permissions = SignerPermissions.toJson(
|
val permissions = SignerPermissions.toJson(
|
||||||
listOf(
|
listOf(
|
||||||
@@ -192,19 +207,53 @@ class AccountViewModel(
|
|||||||
|
|
||||||
val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected")
|
val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected")
|
||||||
val signer = ExternalSignerProxy(handler, result.pubkey)
|
val signer = ExternalSignerProxy(handler, result.pubkey)
|
||||||
|
|
||||||
|
// Update signer
|
||||||
nostr.setSigner(signer)
|
nostr.setSigner(signer)
|
||||||
// Set credentials in persistent storage
|
// Store the signer in the secret storage
|
||||||
secretStore.set("user_signer", "nip55://${result.packageName}/${result.pubkey.toHex()}")
|
secretStore.set(
|
||||||
// Update local state
|
KEY_USER_SIGNER,
|
||||||
_signerRequired.value = false
|
"nip55://${result.packageName}/${result.pubkey.toHex()}"
|
||||||
|
)
|
||||||
|
// Update local states
|
||||||
|
_state.update { it.copy(signerRequired = false, isBusy = false) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw Exception("Notice: ${e.message}")
|
_state.update { it.copy(isBusy = false) }
|
||||||
} finally {
|
showError("Notice: ${e.message}")
|
||||||
appViewModel.setBusy(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isExternalSignerAvailable(): Boolean {
|
fun isExternalSignerAvailable(): Boolean {
|
||||||
return externalSignerHandler?.isAvailable() == true
|
return externalSignerHandler?.isAvailable() == true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun createIdentity(
|
||||||
|
name: String,
|
||||||
|
bio: String?,
|
||||||
|
picture: ByteArray?,
|
||||||
|
contentType: String? = null
|
||||||
|
) {
|
||||||
|
_state.update { it.copy(isBusy = true) }
|
||||||
|
|
||||||
|
val keys = Keys.generate()
|
||||||
|
val secret = keys.secretKey().toBech32()
|
||||||
|
|
||||||
|
try {
|
||||||
|
val avatarUrl = picture?.let {
|
||||||
|
mediaRepository.blossomUpload(nostr.signer.get(), it, contentType ?: "image/jpeg")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create identity
|
||||||
|
nostr.profiles.createIdentity(keys = keys, name = name, bio = bio, picture = avatarUrl)
|
||||||
|
|
||||||
|
// Persist the secret in the secret storage
|
||||||
|
secretStore.set(KEY_USER_SIGNER, secret)
|
||||||
|
|
||||||
|
// Update local states
|
||||||
|
_state.update { it.copy(isBusy = false, signerRequired = false) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
_state.update { it.copy(isBusy = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package su.reya.coop.viewmodel
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import su.reya.coop.repository.ErrorRepository
|
||||||
|
|
||||||
|
abstract class BaseViewModel : ViewModel() {
|
||||||
|
protected fun showError(message: String) {
|
||||||
|
ErrorRepository.showError(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
package su.reya.coop.viewmodel
|
||||||
|
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
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.RelayUrl
|
||||||
|
import rust.nostr.sdk.Tag
|
||||||
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
|
import su.reya.coop.Room
|
||||||
|
import su.reya.coop.nostr.Nostr
|
||||||
|
import su.reya.coop.repository.MediaRepository
|
||||||
|
import su.reya.coop.roomId
|
||||||
|
|
||||||
|
data class ChatState(
|
||||||
|
val rooms: Set<Room> = emptySet(),
|
||||||
|
val isSyncing: Boolean = false,
|
||||||
|
val isPartialProcessedGiftWrap: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
|
||||||
|
private val mediaRepository = MediaRepository()
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(ChatState())
|
||||||
|
val state = combine(
|
||||||
|
_state,
|
||||||
|
nostr.messages.messageSyncState
|
||||||
|
) { local, state -> local.copy(isSyncing = state.isSyncing) }.stateIn(
|
||||||
|
viewModelScope,
|
||||||
|
SharingStarted.WhileSubscribed(5000),
|
||||||
|
ChatState()
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
||||||
|
val newEvents = _newEvents.asSharedFlow()
|
||||||
|
|
||||||
|
private val _sentReports = MutableSharedFlow<Map<EventId, List<RelayUrl>>>()
|
||||||
|
val sentReport = _sentReports.asSharedFlow()
|
||||||
|
|
||||||
|
val chatRooms = state.map { it.rooms }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet())
|
||||||
|
|
||||||
|
val isSyncing = state.map { it.isSyncing }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||||
|
|
||||||
|
val isPartialProcessedGiftWrap = state.map { it.isPartialProcessedGiftWrap }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
nostr.waitUntilInitialized()
|
||||||
|
|
||||||
|
// Observe message sync progress
|
||||||
|
launch {
|
||||||
|
nostr.messages.messageSyncState.collect { syncState ->
|
||||||
|
// When at least some messages are processed, allow UI to show the list
|
||||||
|
if (syncState.processedCount > 0) {
|
||||||
|
_state.update { it.copy(isPartialProcessedGiftWrap = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh UI every 10 messages OR when sync is fully done
|
||||||
|
if (syncState.processedCount % 10 == 0 || !syncState.isSyncing) {
|
||||||
|
refreshChatRooms()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe new messages
|
||||||
|
launch {
|
||||||
|
nostr.newEvents.collect { event ->
|
||||||
|
val roomId = event.roomId()
|
||||||
|
val existingRoom = _state.value.rooms.firstOrNull { it.id == roomId }
|
||||||
|
|
||||||
|
if (existingRoom == null) {
|
||||||
|
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@collect
|
||||||
|
val newRoom = Room.new(event, currentUser)
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
rooms = (it.rooms + newRoom).sortedDescending().toSet()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updateRoomList(roomId, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
_newEvents.emit(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial load of rooms
|
||||||
|
refreshChatRooms()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createChatRoom(to: List<PublicKey>): Long {
|
||||||
|
try {
|
||||||
|
if (to.isEmpty()) {
|
||||||
|
throw IllegalArgumentException("At least one recipient is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current user
|
||||||
|
val currentUser = nostr.signer.publicKeyFlow.value
|
||||||
|
?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
|
// 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 = _state.value.rooms.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
|
||||||
|
_state.update { it.copy(rooms = (it.rooms + room).sortedDescending().toSet()) }
|
||||||
|
|
||||||
|
return room.id
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalArgumentException("Failed to create room: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getChatRoom(id: Long): Room? {
|
||||||
|
return _state.value.rooms.firstOrNull { it.id == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun refreshChatRooms() {
|
||||||
|
try {
|
||||||
|
val rooms = nostr.messages.getChatRooms() ?: emptySet()
|
||||||
|
_state.update { currentState ->
|
||||||
|
val merged = currentState.rooms.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
|
||||||
|
currentState.copy(rooms = merged.values.sortedDescending().toSet())
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> {
|
||||||
|
try {
|
||||||
|
return nostr.messages.getChatRoomMessages(roomId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun chatRoomConnect(roomId: Long) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
||||||
|
val members = room.members
|
||||||
|
|
||||||
|
nostr.messages.chatRoomConnect(members.toList())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendMessage(roomId: Long, message: String, replies: List<EventId> = emptyList()) {
|
||||||
|
if (message.isEmpty()) {
|
||||||
|
showError("Message cannot be empty")
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
||||||
|
nostr.messages.sendMessage(
|
||||||
|
to = room.members,
|
||||||
|
content = message,
|
||||||
|
subject = room.subject,
|
||||||
|
replies = replies,
|
||||||
|
onRumorCreated = { event ->
|
||||||
|
updateRoomList(roomId, event)
|
||||||
|
viewModelScope.launch { _newEvents.emit(event) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun sendFileMessage(
|
||||||
|
roomId: Long,
|
||||||
|
file: ByteArray?,
|
||||||
|
contentType: String? = "image/jpeg",
|
||||||
|
replies: List<EventId> = emptyList()
|
||||||
|
) {
|
||||||
|
if (file == null) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
val uri = mediaRepository.blossomUpload(nostr.signer.get(), file, contentType)
|
||||||
|
if (uri != null) sendMessage(roomId, uri, replies)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalArgumentException("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isMessageSent(id: EventId): Boolean {
|
||||||
|
val giftWrapId = nostr.messages.rumorMap[id]
|
||||||
|
|
||||||
|
if (giftWrapId != null) {
|
||||||
|
val isSent = nostr.messages.sentEvents[giftWrapId]?.isNotEmpty() ?: false
|
||||||
|
return isSent
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) {
|
||||||
|
_state.update { currentState ->
|
||||||
|
val updatedRooms = currentState.rooms.map { room ->
|
||||||
|
if (room.id == roomId) {
|
||||||
|
room.copy(
|
||||||
|
lastMessage = newMessage.content(),
|
||||||
|
createdAt = newMessage.createdAt()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
room
|
||||||
|
}
|
||||||
|
}.sortedDescending().toSet()
|
||||||
|
currentState.copy(rooms = updatedRooms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetInternalState() {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
rooms = emptySet(),
|
||||||
|
isPartialProcessedGiftWrap = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
package su.reya.coop.viewmodel
|
||||||
|
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.awaitCancellation
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.RelayMetadata
|
||||||
|
import rust.nostr.sdk.RelayUrl
|
||||||
|
import rust.nostr.sdk.Timestamp
|
||||||
|
import su.reya.coop.Profile
|
||||||
|
import su.reya.coop.nostr.Nostr
|
||||||
|
import su.reya.coop.repository.MediaRepository
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
data class NostrAppState(
|
||||||
|
val isBusy: Boolean = false,
|
||||||
|
val isRelayListEmpty: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
|
||||||
|
private val mediaRepository = MediaRepository()
|
||||||
|
|
||||||
|
private val alwaysRunTasks = flow {
|
||||||
|
coroutineScope {
|
||||||
|
val observerJob = launch { runObserver() }
|
||||||
|
val batchingJob = launch { runMetadataBatching() }
|
||||||
|
try {
|
||||||
|
emit(Unit)
|
||||||
|
awaitCancellation()
|
||||||
|
} finally {
|
||||||
|
observerJob.cancel()
|
||||||
|
batchingJob.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _appState = MutableStateFlow(NostrAppState())
|
||||||
|
val appState: StateFlow<NostrAppState> =
|
||||||
|
combine(_appState, alwaysRunTasks) { state, _ -> state }.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
|
initialValue = NostrAppState()
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
|
||||||
|
val contactList = _contactList.asStateFlow()
|
||||||
|
|
||||||
|
private val profilesMutex = Mutex()
|
||||||
|
private val profiles = mutableMapOf<PublicKey, MutableStateFlow<Profile?>>()
|
||||||
|
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
|
||||||
|
private val seenPublicKeys = mutableSetOf<PublicKey>()
|
||||||
|
|
||||||
|
val isBusy = appState.map { it.isBusy }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||||
|
val isRelayListEmpty = appState.map { it.isRelayListEmpty }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
val currentUserProfile = nostr.signer.publicKeyFlow
|
||||||
|
.flatMapLatest { pubkey ->
|
||||||
|
if (pubkey != null) getMetadata(pubkey) else flowOf(null)
|
||||||
|
}
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Automatically reconnect bootstrap relays
|
||||||
|
reconnect()
|
||||||
|
|
||||||
|
// Observe the signer state and verify the relay list
|
||||||
|
observeSignerAndCheckRelays()
|
||||||
|
|
||||||
|
// Get all local stored metadata
|
||||||
|
getCacheMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reconnect() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
nostr.waitUntilInitialized()
|
||||||
|
nostr.reconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runObserver() = coroutineScope {
|
||||||
|
// Observe contact list updates
|
||||||
|
launch {
|
||||||
|
nostr.profiles.contactListUpdates.collect { contacts ->
|
||||||
|
_contactList.value = contacts.toSet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe metadata updates
|
||||||
|
launch {
|
||||||
|
nostr.profiles.metadataUpdates.collect { (pubkey, metadata) ->
|
||||||
|
updateMetadata(pubkey, Profile(pubkey, metadata))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runMetadataBatching() = coroutineScope {
|
||||||
|
// Wait until the client is ready
|
||||||
|
nostr.waitUntilInitialized()
|
||||||
|
|
||||||
|
val batch = mutableSetOf<PublicKey>()
|
||||||
|
val timeout = 500L // 500ms timeout for batching
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
// Get the first pubkey
|
||||||
|
val firstKey = metadataRequestChannel.receive()
|
||||||
|
batch.add(firstKey)
|
||||||
|
|
||||||
|
// Get current time
|
||||||
|
val lastFlushTime = Clock.System.now().toEpochMilliseconds()
|
||||||
|
|
||||||
|
while (batch.isNotEmpty()) {
|
||||||
|
// Get the next pubkey
|
||||||
|
val nextKey = withTimeoutOrNull(timeout.milliseconds) {
|
||||||
|
metadataRequestChannel.receive()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only add the pubkey if it's not null
|
||||||
|
if (nextKey != null) batch.add(nextKey)
|
||||||
|
|
||||||
|
// Get current time
|
||||||
|
val now = Clock.System.now().toEpochMilliseconds()
|
||||||
|
|
||||||
|
// Check if the batch is full or timeout has passed
|
||||||
|
if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) {
|
||||||
|
val keysToRequest = batch.toList()
|
||||||
|
batch.clear()
|
||||||
|
|
||||||
|
nostr.profiles.fetchMetadataBatch(keysToRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getCacheMetadata() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
// Wait until the client is ready
|
||||||
|
nostr.waitUntilInitialized()
|
||||||
|
|
||||||
|
nostr.profiles.getAllCacheMetadata().forEach { (pubkey, metadata) ->
|
||||||
|
// Update the metadata state
|
||||||
|
updateMetadata(pubkey, Profile(pubkey, metadata))
|
||||||
|
// Update seenPublicKeys to avoid duplicate requests
|
||||||
|
seenPublicKeys.add(pubkey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeSignerAndCheckRelays() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
// Wait until the client is ready
|
||||||
|
nostr.waitUntilInitialized()
|
||||||
|
|
||||||
|
// Wait until a signer is explicitly set (which updates publicKeyFlow)
|
||||||
|
val currentUser = nostr.signer.publicKeyFlow.filterNotNull().first()
|
||||||
|
|
||||||
|
// Get all metadata for the current user
|
||||||
|
nostr.profiles.getUserMetadata()
|
||||||
|
|
||||||
|
// Small delay to ensure all relays are connected
|
||||||
|
delay(2.seconds)
|
||||||
|
|
||||||
|
// Check if the relay list is empty
|
||||||
|
val relays = nostr.relays.getMsgRelays(currentUser)
|
||||||
|
if (relays.isEmpty()) _appState.update { it.copy(isRelayListEmpty = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestMetadata(pubkey: PublicKey) {
|
||||||
|
if (seenPublicKeys.add(pubkey)) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
metadataRequestChannel.send(pubkey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateMetadata(pubkey: PublicKey, profile: Profile) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
profilesMutex.withLock {
|
||||||
|
profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMetadata(pubkey: PublicKey): StateFlow<Profile?> {
|
||||||
|
val flow = profiles.getOrPut(pubkey) { MutableStateFlow(null) }
|
||||||
|
if (flow.value == null) requestMetadata(pubkey)
|
||||||
|
|
||||||
|
return flow.asStateFlow()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetInternalState() {
|
||||||
|
_contactList.value = emptySet()
|
||||||
|
_appState.update {
|
||||||
|
it.copy(
|
||||||
|
isRelayListEmpty = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissRelayWarning() {
|
||||||
|
_appState.update { it.copy(isRelayListEmpty = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateProfile(
|
||||||
|
name: String? = null,
|
||||||
|
bio: String? = null,
|
||||||
|
picture: ByteArray? = null,
|
||||||
|
contentType: String? = null
|
||||||
|
) {
|
||||||
|
_appState.update { it.copy(isBusy = true) }
|
||||||
|
try {
|
||||||
|
val avatarUrl =
|
||||||
|
picture?.let {
|
||||||
|
mediaRepository.blossomUpload(
|
||||||
|
nostr.signer.get(),
|
||||||
|
it,
|
||||||
|
contentType ?: "image/jpeg"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl)
|
||||||
|
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
||||||
|
|
||||||
|
// Update the metadata state after successfully published
|
||||||
|
updateMetadata(currentUser, Profile(currentUser, newMetadata))
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
_appState.update { it.copy(isBusy = false) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun refetchMsgRelays() {
|
||||||
|
val currentUser = nostr.signer.getPublicKeyAsync() ?: return
|
||||||
|
val relays = nostr.relays.fetchMsgRelays(currentUser)
|
||||||
|
|
||||||
|
if (relays.isNotEmpty()) dismissRelayWarning()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun useDefaultMsgRelayList() {
|
||||||
|
try {
|
||||||
|
val defaultRelays = nostr.relays.getDefaultMsgRelayList()
|
||||||
|
nostr.relays.setMsgRelays(defaultRelays)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun currentUserRelayList(): Map<RelayUrl, RelayMetadata?> {
|
||||||
|
try {
|
||||||
|
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
||||||
|
return nostr.relays.getRelayList(currentUser)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
return emptyMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addInboxRelay(relay: String) {
|
||||||
|
try {
|
||||||
|
val relayUrl = RelayUrl.parse(relay)
|
||||||
|
val relays = currentUserRelayList().toMutableMap()
|
||||||
|
relays[relayUrl] = RelayMetadata.WRITE
|
||||||
|
|
||||||
|
nostr.relays.setRelaylist(relays)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addOutboxRelay(relay: String) {
|
||||||
|
try {
|
||||||
|
val relayUrl = RelayUrl.parse(relay)
|
||||||
|
val relays = currentUserRelayList().toMutableMap()
|
||||||
|
relays[relayUrl] = RelayMetadata.READ
|
||||||
|
|
||||||
|
nostr.relays.setRelaylist(relays)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun removeRelay(relay: String) {
|
||||||
|
try {
|
||||||
|
val relayUrl = RelayUrl.parse(relay)
|
||||||
|
val relays = currentUserRelayList().toMutableMap()
|
||||||
|
relays.remove(relayUrl)
|
||||||
|
|
||||||
|
nostr.relays.setRelaylist(relays)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun currentUserMsgRelayList(): List<RelayUrl> {
|
||||||
|
try {
|
||||||
|
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
||||||
|
return nostr.relays.getMsgRelays(currentUser)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addMsgRelay(relay: String) {
|
||||||
|
try {
|
||||||
|
val relayUrl = RelayUrl.parse(relay)
|
||||||
|
val relays = currentUserMsgRelayList().toMutableSet()
|
||||||
|
relays.add(relayUrl)
|
||||||
|
|
||||||
|
nostr.relays.setMsgRelays(relays.toList())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun removeMsgRelay(relay: String) {
|
||||||
|
try {
|
||||||
|
val relayUrl = RelayUrl.parse(relay)
|
||||||
|
val relays = currentUserMsgRelayList().toMutableSet()
|
||||||
|
relays.remove(relayUrl)
|
||||||
|
|
||||||
|
nostr.relays.setMsgRelays(relays.toList())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun newContact(publicKey: PublicKey) {
|
||||||
|
if (publicKey in contactList.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
val updated = contactList.value + publicKey
|
||||||
|
// Publish new event
|
||||||
|
nostr.profiles.setContactList(updated.toList())
|
||||||
|
// Optimistic local update
|
||||||
|
_contactList.update { it + publicKey }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addContact(address: String): Boolean {
|
||||||
|
val pubkey = try {
|
||||||
|
if (address.contains("@")) {
|
||||||
|
nostr.profiles.searchByAddress(address)
|
||||||
|
} else {
|
||||||
|
PublicKey.parse(address)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Invalid contact address: ${e.message}")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return run {
|
||||||
|
newContact(pubkey)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeContact(publicKey: PublicKey) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
if (publicKey !in contactList.value) return@launch
|
||||||
|
|
||||||
|
try {
|
||||||
|
val updated = contactList.value - publicKey
|
||||||
|
// Publish new event
|
||||||
|
nostr.profiles.setContactList(updated.toList())
|
||||||
|
// Optimistic local update
|
||||||
|
_contactList.update { it - publicKey }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun searchByAddress(query: String): PublicKey? {
|
||||||
|
try {
|
||||||
|
return nostr.profiles.searchByAddress(query)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun searchByNostr(query: String): List<PublicKey> {
|
||||||
|
try {
|
||||||
|
return nostr.profiles.searchByNostr(query)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun verifyActivity(pubkey: PublicKey): Timestamp? {
|
||||||
|
return try {
|
||||||
|
nostr.profiles.verifyActivity(pubkey)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun verifyContact(pubkey: PublicKey): Boolean {
|
||||||
|
return try {
|
||||||
|
nostr.profiles.verifyContact(pubkey)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun mutualContacts(pubkey: PublicKey): Set<PublicKey> {
|
||||||
|
return try {
|
||||||
|
nostr.profiles.mutualContacts(pubkey)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
setOf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
package su.reya.coop.viewmodels
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import io.ktor.client.HttpClient
|
|
||||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
|
||||||
import io.ktor.serialization.kotlinx.json.json
|
|
||||||
import kotlinx.coroutines.NonCancellable
|
|
||||||
import kotlinx.coroutines.channels.Channel
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import su.reya.coop.blossom.BlossomClient
|
|
||||||
import su.reya.coop.nostr.Nostr
|
|
||||||
import su.reya.coop.storage.SecretStorage
|
|
||||||
|
|
||||||
class AppViewModel(
|
|
||||||
private val nostr: Nostr,
|
|
||||||
private val secretStore: SecretStorage
|
|
||||||
) : ViewModel() {
|
|
||||||
private val _isBusy = MutableStateFlow(false)
|
|
||||||
val isBusy = _isBusy.asStateFlow()
|
|
||||||
|
|
||||||
private val _isNotificationBannerDismissed = MutableStateFlow(false)
|
|
||||||
val isNotificationBannerDismissed = _isNotificationBannerDismissed.asStateFlow()
|
|
||||||
|
|
||||||
private val _errorEvents = Channel<String>(Channel.BUFFERED)
|
|
||||||
val errorEvents = _errorEvents.receiveAsFlow()
|
|
||||||
|
|
||||||
init {
|
|
||||||
checkNotificationBannerDismissedStatus()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setBusy(busy: Boolean) {
|
|
||||||
_isBusy.value = busy
|
|
||||||
}
|
|
||||||
|
|
||||||
fun showError(message: String) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_errorEvents.send(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun checkNotificationBannerDismissedStatus() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_isNotificationBannerDismissed.value =
|
|
||||||
secretStore.get("notification_banner_dismissed") == "true"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dismissNotificationBanner() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
secretStore.set("notification_banner_dismissed", "true")
|
|
||||||
_isNotificationBannerDismissed.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun blossomUpload(file: ByteArray, contentType: String? = "image/jpeg"): String? {
|
|
||||||
val blossom = BlossomClient(
|
|
||||||
url = "https://blossom.band",
|
|
||||||
client = HttpClient {
|
|
||||||
install(ContentNegotiation) {
|
|
||||||
json(Json {
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
prettyPrint = true
|
|
||||||
isLenient = true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
val descriptor = blossom.upload(
|
|
||||||
file = file,
|
|
||||||
contentType = contentType,
|
|
||||||
signer = nostr.signer.get()
|
|
||||||
)
|
|
||||||
|
|
||||||
return descriptor?.url
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCleared() {
|
|
||||||
super.onCleared()
|
|
||||||
viewModelScope.launch {
|
|
||||||
withContext(NonCancellable) {
|
|
||||||
nostr.disconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
package su.reya.coop.viewmodels
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import kotlinx.coroutines.coroutineScope
|
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import rust.nostr.sdk.EventBuilder
|
|
||||||
import rust.nostr.sdk.EventId
|
|
||||||
import rust.nostr.sdk.Kind
|
|
||||||
import rust.nostr.sdk.KindStandard
|
|
||||||
import rust.nostr.sdk.PublicKey
|
|
||||||
import rust.nostr.sdk.Tag
|
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
|
||||||
import su.reya.coop.nostr.Nostr
|
|
||||||
import su.reya.coop.nostr.Room
|
|
||||||
import su.reya.coop.nostr.roomId
|
|
||||||
|
|
||||||
class ChatViewModel(
|
|
||||||
private val nostr: Nostr,
|
|
||||||
private val appViewModel: AppViewModel
|
|
||||||
) : ViewModel() {
|
|
||||||
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
|
|
||||||
val chatRooms = _chatRooms.asStateFlow()
|
|
||||||
|
|
||||||
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
|
||||||
val newEvents = _newEvents.asSharedFlow()
|
|
||||||
|
|
||||||
private val _isPartialProcessedGiftWrap = MutableStateFlow(false)
|
|
||||||
val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow()
|
|
||||||
|
|
||||||
val isSyncing = nostr.messages.messageSyncState.map { it.isSyncing }
|
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
|
||||||
|
|
||||||
suspend fun bindObservers() = coroutineScope {
|
|
||||||
launch {
|
|
||||||
nostr.messages.messageSyncState.collect { state ->
|
|
||||||
if (state.processedCount > 0) {
|
|
||||||
_isPartialProcessedGiftWrap.value = true
|
|
||||||
}
|
|
||||||
if (state.processedCount % 10 == 0 || !state.isSyncing) {
|
|
||||||
refreshChatRooms()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
launch {
|
|
||||||
nostr.newEvents.collect { event ->
|
|
||||||
val roomId = event.roomId()
|
|
||||||
val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId }
|
|
||||||
|
|
||||||
if (existingRoom == null) {
|
|
||||||
val currentUser = nostr.signer.currentUser
|
|
||||||
if (currentUser != null) {
|
|
||||||
val newRoom = Room.new(event, currentUser)
|
|
||||||
_chatRooms.update { (it + newRoom).sortedDescending().toSet() }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
updateRoomList(roomId, event)
|
|
||||||
}
|
|
||||||
|
|
||||||
_newEvents.emit(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createChatRoom(to: List<PublicKey>): Long {
|
|
||||||
try {
|
|
||||||
val currentUser =
|
|
||||||
nostr.signer.currentUser ?: throw IllegalStateException("User not signed in")
|
|
||||||
if (to.isEmpty()) throw IllegalArgumentException("At least one recipient is required")
|
|
||||||
|
|
||||||
val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), "")
|
|
||||||
.tags(to.map { Tag.publicKey(it) })
|
|
||||||
.finalizeUnsigned(currentUser)
|
|
||||||
|
|
||||||
val id = rumor.roomId()
|
|
||||||
val existingRoom = _chatRooms.value.firstOrNull { it.id == id }
|
|
||||||
|
|
||||||
if (existingRoom != null) {
|
|
||||||
return existingRoom.id
|
|
||||||
}
|
|
||||||
|
|
||||||
val room = Room.new(rumor, currentUser)
|
|
||||||
_chatRooms.update { currentRooms ->
|
|
||||||
(currentRooms + room).sortedDescending().toSet()
|
|
||||||
}
|
|
||||||
|
|
||||||
return room.id
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalArgumentException("Failed to create room: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getChatRoom(id: Long): Room? {
|
|
||||||
return chatRooms.value.firstOrNull { it.id == id }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getChatRooms() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val rooms = nostr.messages.getChatRooms() ?: emptySet()
|
|
||||||
mergeChatRooms(rooms)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun refreshChatRooms() {
|
|
||||||
try {
|
|
||||||
val rooms = nostr.messages.getChatRooms() ?: emptySet()
|
|
||||||
mergeChatRooms(rooms)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun mergeChatRooms(rooms: Set<Room>) {
|
|
||||||
_chatRooms.update { currentRooms ->
|
|
||||||
val merged = currentRooms.associateBy { it.id }.toMutableMap()
|
|
||||||
rooms.forEach { room ->
|
|
||||||
merged[room.id] = room
|
|
||||||
}
|
|
||||||
merged.values.sortedDescending().toSet()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> {
|
|
||||||
try {
|
|
||||||
return nostr.messages.getChatRoomMessages(roomId)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
return emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun chatRoomConnect(roomId: Long) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
|
||||||
nostr.messages.chatRoomConnect(room.members.toList())
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendMessage(
|
|
||||||
roomId: Long,
|
|
||||||
message: String,
|
|
||||||
replies: List<EventId> = emptyList()
|
|
||||||
) {
|
|
||||||
if (message.isEmpty()) {
|
|
||||||
appViewModel.showError("Message cannot be empty")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
|
||||||
nostr.messages.sendMessage(
|
|
||||||
to = room.members,
|
|
||||||
content = message,
|
|
||||||
subject = room.subject,
|
|
||||||
replies = replies,
|
|
||||||
onRumorCreated = { event ->
|
|
||||||
updateRoomList(roomId, event)
|
|
||||||
viewModelScope.launch { _newEvents.emit(event) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun sendFileMessage(
|
|
||||||
roomId: Long,
|
|
||||||
file: ByteArray?,
|
|
||||||
contentType: String? = "image/jpeg",
|
|
||||||
replies: List<EventId> = emptyList()
|
|
||||||
) {
|
|
||||||
if (file == null) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
val uri = appViewModel.blossomUpload(file, contentType)
|
|
||||||
?: throw IllegalArgumentException("Failed to upload file")
|
|
||||||
|
|
||||||
sendMessage(roomId, uri, replies)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalArgumentException("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isMessageSent(id: EventId): Boolean {
|
|
||||||
val giftWrapId = nostr.messages.rumorMap[id]
|
|
||||||
return if (giftWrapId != null) {
|
|
||||||
nostr.messages.sentEvents[giftWrapId]?.isNotEmpty() ?: false
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) {
|
|
||||||
_chatRooms.update { currentRooms ->
|
|
||||||
currentRooms.map { room ->
|
|
||||||
if (room.id == roomId) {
|
|
||||||
room.copy(
|
|
||||||
lastMessage = newMessage.content(),
|
|
||||||
createdAt = newMessage.createdAt()
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
room
|
|
||||||
}
|
|
||||||
}.sortedDescending().toSet()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
package su.reya.coop.viewmodels
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import kotlinx.coroutines.channels.Channel
|
|
||||||
import kotlinx.coroutines.coroutineScope
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
|
||||||
import rust.nostr.sdk.Metadata
|
|
||||||
import rust.nostr.sdk.PublicKey
|
|
||||||
import rust.nostr.sdk.Timestamp
|
|
||||||
import su.reya.coop.nostr.Nostr
|
|
||||||
import kotlin.time.Clock
|
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
|
|
||||||
class ProfileViewModel(
|
|
||||||
private val nostr: Nostr,
|
|
||||||
private val appViewModel: AppViewModel
|
|
||||||
) : ViewModel() {
|
|
||||||
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
|
|
||||||
val contactList = _contactList.asStateFlow()
|
|
||||||
|
|
||||||
private val _metadataStore = mutableMapOf<PublicKey, MutableStateFlow<Metadata?>>()
|
|
||||||
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
|
|
||||||
private val seenPublicKeys = mutableSetOf<PublicKey>()
|
|
||||||
|
|
||||||
init {
|
|
||||||
getCacheMetadata()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun bindObservers() = coroutineScope {
|
|
||||||
launch {
|
|
||||||
nostr.profiles.contactListUpdates.collect { contacts ->
|
|
||||||
_contactList.value = contacts.toSet()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
launch {
|
|
||||||
nostr.profiles.metadataUpdates.collect { (pubkey, metadata) ->
|
|
||||||
updateMetadata(pubkey, metadata)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
launch { runMetadataBatching() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateMetadata(pubkey: PublicKey, metadata: Metadata) {
|
|
||||||
_metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }.value = metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getMetadata(pubkey: PublicKey): StateFlow<Metadata?> {
|
|
||||||
val flow = _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }
|
|
||||||
if (flow.value == null) {
|
|
||||||
requestMetadata(pubkey)
|
|
||||||
}
|
|
||||||
return flow.asStateFlow()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requestMetadata(pubkey: PublicKey) {
|
|
||||||
if (seenPublicKeys.add(pubkey)) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
metadataRequestChannel.send(pubkey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getCacheMetadata() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
nostr.waitUntilInitialized()
|
|
||||||
val results = nostr.profiles.getAllCacheMetadata()
|
|
||||||
results.forEach { (pubkey, metadata) ->
|
|
||||||
updateMetadata(pubkey, metadata)
|
|
||||||
seenPublicKeys.add(pubkey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun runMetadataBatching() = coroutineScope {
|
|
||||||
nostr.waitUntilInitialized()
|
|
||||||
val batch = mutableSetOf<PublicKey>()
|
|
||||||
val timeout = 500L
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
val firstKey = metadataRequestChannel.receive()
|
|
||||||
batch.add(firstKey)
|
|
||||||
val lastFlushTime = Clock.System.now().toEpochMilliseconds()
|
|
||||||
|
|
||||||
while (batch.isNotEmpty()) {
|
|
||||||
val nextKey = withTimeoutOrNull(timeout.milliseconds) {
|
|
||||||
metadataRequestChannel.receive()
|
|
||||||
}
|
|
||||||
if (nextKey != null) batch.add(nextKey)
|
|
||||||
val now = Clock.System.now().toEpochMilliseconds()
|
|
||||||
|
|
||||||
if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) {
|
|
||||||
val keysToRequest = batch.toList()
|
|
||||||
batch.clear()
|
|
||||||
nostr.profiles.fetchMetadataBatch(keysToRequest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun updateProfile(
|
|
||||||
name: String? = null,
|
|
||||||
bio: String? = null,
|
|
||||||
picture: ByteArray? = null,
|
|
||||||
contentType: String? = "image/jpeg"
|
|
||||||
) {
|
|
||||||
appViewModel.setBusy(true)
|
|
||||||
try {
|
|
||||||
val avatarUrl = picture?.let { appViewModel.blossomUpload(it, contentType) }
|
|
||||||
val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl)
|
|
||||||
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
|
||||||
|
|
||||||
updateMetadata(currentUser, newMetadata)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
} finally {
|
|
||||||
appViewModel.setBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun addContact(address: String): Boolean {
|
|
||||||
val pubkey = try {
|
|
||||||
if (address.contains("@")) {
|
|
||||||
nostr.profiles.searchByAddress(address)
|
|
||||||
} else {
|
|
||||||
PublicKey.parse(address)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Invalid contact address: ${e.message}")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pubkey in contactList.value) return true
|
|
||||||
|
|
||||||
return try {
|
|
||||||
val updated = contactList.value + pubkey
|
|
||||||
nostr.profiles.setContactList(updated.toList())
|
|
||||||
_contactList.update { it + pubkey }
|
|
||||||
true
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun removeContact(publicKey: PublicKey) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
if (publicKey !in contactList.value) return@launch
|
|
||||||
try {
|
|
||||||
val updated = contactList.value - publicKey
|
|
||||||
nostr.profiles.setContactList(updated.toList())
|
|
||||||
_contactList.update { it - publicKey }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun searchByAddress(query: String): PublicKey? {
|
|
||||||
try {
|
|
||||||
return nostr.profiles.searchByAddress(query)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun searchByNostr(query: String): List<PublicKey> {
|
|
||||||
try {
|
|
||||||
return nostr.profiles.searchByNostr(query)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
return emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun verifyActivity(pubkey: PublicKey): Timestamp? {
|
|
||||||
return try {
|
|
||||||
nostr.profiles.verifyActivity(pubkey)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun verifyContact(pubkey: PublicKey): Boolean {
|
|
||||||
return try {
|
|
||||||
nostr.profiles.verifyContact(pubkey)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun mutualContacts(pubkey: PublicKey): Set<PublicKey> {
|
|
||||||
return try {
|
|
||||||
nostr.profiles.mutualContacts(pubkey)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
setOf()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getUserMetadata() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
nostr.profiles.getUserMetadata()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
package su.reya.coop.viewmodels
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import rust.nostr.sdk.PublicKey
|
|
||||||
import rust.nostr.sdk.RelayMetadata
|
|
||||||
import rust.nostr.sdk.RelayUrl
|
|
||||||
import su.reya.coop.nostr.Nostr
|
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
|
|
||||||
class RelayViewModel(
|
|
||||||
private val nostr: Nostr,
|
|
||||||
private val appViewModel: AppViewModel,
|
|
||||||
private val chatViewModel: ChatViewModel,
|
|
||||||
private val profileViewModel: ProfileViewModel
|
|
||||||
) : ViewModel() {
|
|
||||||
private val _isRelayListEmpty = MutableStateFlow(false)
|
|
||||||
val isRelayListEmpty = _isRelayListEmpty.asStateFlow()
|
|
||||||
|
|
||||||
fun reconnect() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
nostr.waitUntilInitialized()
|
|
||||||
nostr.reconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun observeSignerAndCheckRelays() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
while (true) {
|
|
||||||
val pubkey = nostr.signer.currentUser
|
|
||||||
if (pubkey != null) {
|
|
||||||
val rooms = nostr.messages.getChatRooms() ?: emptySet()
|
|
||||||
if (rooms.isNotEmpty()) {
|
|
||||||
chatViewModel.mergeChatRooms(rooms)
|
|
||||||
// Note: isPartialProcessedGiftWrap is in ChatViewModel
|
|
||||||
// We might need to expose a way to set it or just let it be updated by observers
|
|
||||||
}
|
|
||||||
|
|
||||||
profileViewModel.getUserMetadata()
|
|
||||||
|
|
||||||
delay(2.seconds)
|
|
||||||
|
|
||||||
val relays = nostr.relays.getMsgRelays(pubkey)
|
|
||||||
if (relays.isEmpty()) _isRelayListEmpty.value = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
delay(500.milliseconds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun refetchMsgRelays(pubkey: PublicKey) {
|
|
||||||
val relays = nostr.relays.fetchMsgRelays(pubkey)
|
|
||||||
if (relays.isNotEmpty()) dismissRelayWarning()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dismissRelayWarning() {
|
|
||||||
_isRelayListEmpty.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun useDefaultMsgRelayList() {
|
|
||||||
try {
|
|
||||||
val defaultRelays = nostr.relays.getDefaultMsgRelayList()
|
|
||||||
nostr.relays.setMsgRelays(defaultRelays)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun currentUserRelayList(): Map<RelayUrl, RelayMetadata?> {
|
|
||||||
try {
|
|
||||||
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
|
||||||
return nostr.relays.getRelayList(currentUser)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
return emptyMap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun addInboxRelay(relay: String) {
|
|
||||||
try {
|
|
||||||
val relayUrl = RelayUrl.parse(relay)
|
|
||||||
val relays = currentUserRelayList().toMutableMap()
|
|
||||||
relays[relayUrl] = RelayMetadata.WRITE
|
|
||||||
nostr.relays.setRelaylist(relays)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun addOutboxRelay(relay: String) {
|
|
||||||
try {
|
|
||||||
val relayUrl = RelayUrl.parse(relay)
|
|
||||||
val relays = currentUserRelayList().toMutableMap()
|
|
||||||
relays[relayUrl] = RelayMetadata.READ
|
|
||||||
nostr.relays.setRelaylist(relays)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun removeRelay(relay: String) {
|
|
||||||
try {
|
|
||||||
val relayUrl = RelayUrl.parse(relay)
|
|
||||||
val relays = currentUserRelayList().toMutableMap()
|
|
||||||
relays.remove(relayUrl)
|
|
||||||
nostr.relays.setRelaylist(relays)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun currentUserMsgRelayList(): List<RelayUrl> {
|
|
||||||
try {
|
|
||||||
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
|
||||||
return nostr.relays.getMsgRelays(currentUser)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
return emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun addMsgRelay(relay: String) {
|
|
||||||
try {
|
|
||||||
val relayUrl = RelayUrl.parse(relay)
|
|
||||||
val relays = currentUserMsgRelayList().toMutableSet()
|
|
||||||
relays.add(relayUrl)
|
|
||||||
nostr.relays.setMsgRelays(relays.toList())
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun removeMsgRelay(relay: String) {
|
|
||||||
try {
|
|
||||||
val relayUrl = RelayUrl.parse(relay)
|
|
||||||
val relays = currentUserMsgRelayList().toMutableSet()
|
|
||||||
relays.remove(relayUrl)
|
|
||||||
nostr.relays.setMsgRelays(relays.toList())
|
|
||||||
} catch (e: Exception) {
|
|
||||||
appViewModel.showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user