chore: clean up codebase #42

Merged
reya merged 6 commits from cleanup into master 2026-07-13 01:04:16 +00:00
32 changed files with 1360 additions and 1466 deletions

View File

@@ -26,7 +26,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.platform.LocalContext
import androidx.core.util.Consumer
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
@@ -35,6 +38,8 @@ import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import kotlinx.coroutines.launch
import su.reya.coop.repository.AccountRepository
import su.reya.coop.repository.ChatRepository
import su.reya.coop.screens.ContactListScreen
import su.reya.coop.screens.HomeScreen
import su.reya.coop.screens.ImportScreen
@@ -48,23 +53,15 @@ import su.reya.coop.screens.RequestListScreen
import su.reya.coop.screens.ScanScreen
import su.reya.coop.screens.UpdateProfileScreen
import su.reya.coop.screens.chat.ChatScreen
import su.reya.coop.viewmodel.AccountViewModel
import su.reya.coop.viewmodel.ChatScreenViewModel
import su.reya.coop.viewmodel.ChatViewModel
import su.reya.coop.viewmodel.NostrViewModel
import su.reya.coop.viewmodel.account.AccountViewModel
import su.reya.coop.viewmodel.ProfileCache
val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
error("No NostrViewModel provided")
val LocalProfileCache = staticCompositionLocalOf<ProfileCache> {
error("No ProfileCache provided")
}
val LocalChatViewModel = staticCompositionLocalOf<ChatViewModel> {
error("No ChatViewModel provided")
}
val LocalAccountViewModel = staticCompositionLocalOf<AccountViewModel> {
error("No AccountViewModel provided")
}
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
error("No SnackbarHostState provided")
}
@@ -80,10 +77,33 @@ val LocalScanResult = staticCompositionLocalOf<QrScanResult> {
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
fun App(
nostrViewModel: NostrViewModel,
chatViewModel: ChatViewModel,
accountViewModel: AccountViewModel,
profileCache: ProfileCache,
accountRepository: AccountRepository,
chatRepository: ChatRepository,
) {
val viewModelFactory = remember {
object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val result = when {
modelClass.isAssignableFrom(ChatViewModel::class.java) -> ChatViewModel(
chatRepository
)
modelClass.isAssignableFrom(AccountViewModel::class.java) -> AccountViewModel(
accountRepository
)
else -> throw IllegalArgumentException("Unknown ViewModel class")
}
@Suppress("UNCHECKED_CAST")
return result as T
}
}
}
val accountViewModel: AccountViewModel = viewModel(factory = viewModelFactory)
val chatViewModel: ChatViewModel = viewModel(factory = viewModelFactory)
val context = LocalContext.current
val activity = context as? ComponentActivity
val backStack = rememberNavBackStack(Screen.Home)
@@ -130,7 +150,7 @@ fun App(
}
}
launch {
nostrViewModel.errorEvents.collect { message ->
profileCache.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
@@ -141,7 +161,7 @@ fun App(
fun handleIntent(intent: Intent) {
val screen = Screen.fromIntent(intent)
// Prevent pushing the same screen
if (screen != null && backStack.lastOrNull() != screen) {
if ((screen != null) && (backStack.lastOrNull() != screen)) {
navigator.navigate(screen)
}
}
@@ -174,9 +194,7 @@ fun App(
motionScheme = MotionScheme.expressive(),
) {
CompositionLocalProvider(
LocalNostrViewModel provides nostrViewModel,
LocalChatViewModel provides chatViewModel,
LocalAccountViewModel provides accountViewModel,
LocalProfileCache provides profileCache,
LocalSnackbarHostState provides snackbarHostState,
LocalNavigator provides navigator,
LocalScanResult provides qrScanResult,
@@ -197,43 +215,62 @@ fun App(
),
entryProvider = entryProvider {
entry<Screen.Home> {
HomeScreen()
HomeScreen(accountViewModel, chatViewModel)
}
entry<Screen.RequestList> {
RequestListScreen()
RequestListScreen(chatViewModel)
}
entry<Screen.Onboarding> {
OnboardingScreen()
OnboardingScreen(accountViewModel)
}
entry<Screen.Import> {
ImportScreen()
ImportScreen(accountViewModel)
}
entry<Screen.NewIdentity> {
NewIdentityScreen()
NewIdentityScreen(accountViewModel)
}
entry<Screen.Chat> { key ->
ChatScreen(id = key.id, screening = key.screening)
val factory = remember(key) {
object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return ChatScreenViewModel(
key.id,
key.screening,
accountRepository,
chatRepository
) as T
}
}
}
ChatScreen(
viewModel<ChatScreenViewModel>(
key = key.id.toString(),
factory = factory
),
accountViewModel
)
}
entry<Screen.NewChat> {
NewChatScreen()
NewChatScreen(accountViewModel, chatViewModel)
}
entry<Screen.Profile> { key ->
ProfileScreen(pubkey = key.pubkey)
ProfileScreen(pubkey = key.pubkey, chatViewModel = chatViewModel)
}
entry<Screen.UpdateProfile> {
UpdateProfileScreen()
UpdateProfileScreen(accountViewModel)
}
entry<Screen.Scan> {
ScanScreen()
}
entry<Screen.MyQr> {
MyQrScreen()
MyQrScreen(accountViewModel)
}
entry<Screen.ContactList> {
ContactListScreen()
ContactListScreen(accountViewModel, chatViewModel)
}
entry<Screen.Relay> {
RelayScreen()
RelayScreen(accountViewModel)
}
}
)

View File

@@ -8,15 +8,13 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import kotlinx.coroutines.MainScope
import su.reya.coop.nostr.NostrManager
import su.reya.coop.repository.AccountRepository
import su.reya.coop.repository.ChatRepository
import su.reya.coop.repository.MediaRepository
import su.reya.coop.viewmodel.ChatViewModel
import su.reya.coop.viewmodel.NostrViewModel
import su.reya.coop.viewmodel.account.AccountViewModel
import su.reya.coop.viewmodel.ProfileCache
import kotlin.system.exitProcess
class MainActivity : ComponentActivity() {
@@ -24,31 +22,20 @@ class MainActivity : ComponentActivity() {
val externalSignerLauncher = ExternalSignerLauncher()
}
private val factory by lazy {
object : ViewModelProvider.Factory {
private val storage = AppStore(this@MainActivity)
private val mediaRepository = MediaRepository()
private val nostrViewModel = NostrViewModel(NostrManager.instance)
private val chatViewModel = ChatViewModel(NostrManager.instance, mediaRepository)
private val androidSigner =
AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
private val accountViewModel =
AccountViewModel(NostrManager.instance, storage, mediaRepository, androidSigner)
private val profileCache by lazy { ProfileCache(NostrManager.instance) }
private val scope = MainScope()
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return when {
modelClass.isAssignableFrom(NostrViewModel::class.java) -> nostrViewModel
modelClass.isAssignableFrom(ChatViewModel::class.java) -> chatViewModel
modelClass.isAssignableFrom(AccountViewModel::class.java) -> accountViewModel
else -> throw IllegalArgumentException("Unknown ViewModel class")
} as T
}
}
private val accountRepository by lazy {
val storage = AppStore(this@MainActivity)
val mediaRepository = MediaRepository()
val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
AccountRepository(NostrManager.instance, storage, mediaRepository, scope, androidSigner)
}
private val nostrViewModel: NostrViewModel by viewModels { factory }
private val chatViewModel: ChatViewModel by viewModels { factory }
private val accountViewModel: AccountViewModel by viewModels { factory }
private val chatRepository by lazy {
val mediaRepository = MediaRepository()
ChatRepository(NostrManager.instance, mediaRepository, scope)
}
override fun onCreate(savedInstanceState: Bundle?) {
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
@@ -87,14 +74,14 @@ class MainActivity : ComponentActivity() {
// Keep the splash screen visible until the signer check is complete
splashScreen.setKeepOnScreenCondition {
accountViewModel.state.value.signerRequired == null
accountRepository.state.value.signerRequired == null
}
setContent {
App(
nostrViewModel = nostrViewModel,
chatViewModel = chatViewModel,
accountViewModel = accountViewModel,
profileCache = profileCache,
accountRepository = accountRepository,
chatRepository = chatRepository,
)
}
}

View File

@@ -36,7 +36,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -54,28 +53,26 @@ import coop.composeapp.generated.resources.ic_check
import coop.composeapp.generated.resources.ic_close
import coop.composeapp.generated.resources.ic_plus
import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.Nip05Address
import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalAccountViewModel
import su.reya.coop.LocalChatViewModel
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalProfileCache
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen
import su.reya.coop.shared.Avatar
import su.reya.coop.short
import su.reya.coop.viewmodel.AccountViewModel
import su.reya.coop.viewmodel.ChatViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ContactListScreen() {
fun ContactListScreen(
accountViewModel: AccountViewModel,
chatViewModel: ChatViewModel
) {
val navigator = LocalNavigator.current
val snackbarHostState = LocalSnackbarHostState.current
val nostrViewModel = LocalNostrViewModel.current
val accountViewModel = LocalAccountViewModel.current
val chatViewModel = LocalChatViewModel.current
val contactList by accountViewModel.contactList.collectAsStateWithLifecycle()
var openAddContactDialog by remember { mutableStateOf(false) }
var contactToDelete by remember { mutableStateOf<PublicKey?>(null) }
@@ -192,7 +189,10 @@ fun ContactListScreen() {
)
if (openAddContactDialog) {
AddContactDialog(onDismissRequest = { openAddContactDialog = false })
AddContactDialog(
accountViewModel = accountViewModel,
onDismissRequest = { openAddContactDialog = false }
)
}
if (contactToDelete != null) {
@@ -221,16 +221,15 @@ fun ContactListScreen() {
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun AddContactDialog(onDismissRequest: () -> Unit) {
fun AddContactDialog(
accountViewModel: AccountViewModel,
onDismissRequest: () -> Unit,
) {
val snackbarHostState = LocalSnackbarHostState.current
val nostrViewModel = LocalNostrViewModel.current
val accountViewModel = LocalAccountViewModel.current
val focusRequester = remember { FocusRequester() }
var contact by remember { mutableStateOf("") }
var isError by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
@@ -326,8 +325,8 @@ fun ContactListItem(
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
val nostrViewModel = LocalNostrViewModel.current
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
val profileCache = LocalProfileCache.current
val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) }
val profile by profileFlow.collectAsStateWithLifecycle(initialValue = null)
SegmentedListItem(

View File

@@ -89,31 +89,32 @@ import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalAccountViewModel
import su.reya.coop.LocalChatViewModel
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalProfileCache
import su.reya.coop.LocalScanResult
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Room
import su.reya.coop.RoomKind
import su.reya.coop.RoomUiState
import su.reya.coop.Screen
import su.reya.coop.ago
import su.reya.coop.rememberUiState
import su.reya.coop.shared.Avatar
import su.reya.coop.shared.getExpressiveFontFamily
import su.reya.coop.uiStateFlow
import su.reya.coop.viewmodel.AccountViewModel
import su.reya.coop.viewmodel.ChatViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen() {
fun HomeScreen(
accountViewModel: AccountViewModel,
chatViewModel: ChatViewModel
) {
val context = LocalContext.current
val navigator = LocalNavigator.current
val qrScanResult = LocalScanResult.current
val snackbarHostState = LocalSnackbarHostState.current
val clipboardManager = LocalClipboard.current
val nostrViewModel = LocalNostrViewModel.current
val chatViewModel = LocalChatViewModel.current
val accountViewModel = LocalAccountViewModel.current
val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(true)
@@ -459,7 +460,11 @@ fun HomeScreen() {
}
}
Spacer(modifier = Modifier.size(16.dp))
BottomMenuList(onDismiss = dismissAndRun)
BottomMenuList(
onDismiss = dismissAndRun,
accountViewModel = accountViewModel,
chatViewModel = chatViewModel
)
}
}
}
@@ -609,14 +614,16 @@ fun HomeScreen() {
@Composable
fun NewRequests(requests: List<Room>) {
val navigator = LocalNavigator.current
val nostrViewModel = LocalNostrViewModel.current
val profileCache = LocalProfileCache.current
val total = requests.size
val firstRoom = requests.getOrNull(0)
val secondRoom = requests.getOrNull(1)
val firstRoomState by (firstRoom as Room).rememberUiState(nostrViewModel)
val secondRoomState by (secondRoom ?: firstRoom).rememberUiState(nostrViewModel)
val firstRoomState by (firstRoom as Room).uiStateFlow(profileCache)
.collectAsStateWithLifecycle(RoomUiState())
val secondRoomState by (secondRoom ?: firstRoom).uiStateFlow(profileCache)
.collectAsStateWithLifecycle(RoomUiState())
val supportingText = when {
total == 1 -> {
@@ -687,8 +694,9 @@ fun NewRequests(requests: List<Room>) {
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ChatRoom(room: Room, onClick: () -> Unit) {
val nostrViewModel = LocalNostrViewModel.current
val roomState by room.rememberUiState(nostrViewModel)
val profileCache = LocalProfileCache.current
val roomState by room.uiStateFlow(profileCache)
.collectAsStateWithLifecycle(RoomUiState())
ListItem(
modifier = Modifier.clickable(onClick = onClick),
@@ -731,12 +739,11 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun BottomMenuList(
onDismiss: (suspend () -> Unit) -> Unit
onDismiss: (suspend () -> Unit) -> Unit,
accountViewModel: AccountViewModel,
chatViewModel: ChatViewModel,
) {
val navigator = LocalNavigator.current
val nostrViewModel = LocalNostrViewModel.current
val chatViewModel = LocalChatViewModel.current
val accountViewModel = LocalAccountViewModel.current
val defaultMenuList = listOf(
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },

View File

@@ -50,23 +50,20 @@ import coop.composeapp.generated.resources.ic_scanner
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.Keys
import rust.nostr.sdk.NostrConnectUri
import su.reya.coop.LocalAccountViewModel
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalScanResult
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen
import su.reya.coop.viewmodel.AccountViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ImportScreen() {
fun ImportScreen(viewModel: AccountViewModel) {
val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current
val qrScanResult = LocalScanResult.current
val focusManager = LocalFocusManager.current
val accountViewModel = LocalAccountViewModel.current
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
val accountState by viewModel.state.collectAsStateWithLifecycle()
var secret by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var requirePassword by remember { mutableStateOf(false) }
@@ -250,7 +247,7 @@ fun ImportScreen() {
Spacer(modifier = Modifier.size(16.dp))
Button(
onClick = {
accountViewModel.importIdentity(secret, password)
viewModel.importIdentity(secret, password)
},
modifier = Modifier
.fillMaxWidth()

View File

@@ -21,16 +21,15 @@ import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import io.github.alexzhirkevich.qrose.rememberQrCodePainter
import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalAccountViewModel
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.viewmodel.AccountViewModel
@Composable
fun MyQrScreen() {
fun MyQrScreen(viewModel: AccountViewModel) {
val navigator = LocalNavigator.current
val snackbarHostState = LocalSnackbarHostState.current
val accountViewModel = LocalAccountViewModel.current
val currentUser by accountViewModel.currentUserProfile.collectAsStateWithLifecycle()
val currentUser by viewModel.currentUserProfile.collectAsStateWithLifecycle()
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },

View File

@@ -55,27 +55,26 @@ import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalAccountViewModel
import su.reya.coop.LocalChatViewModel
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalProfileCache
import su.reya.coop.LocalScanResult
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen
import su.reya.coop.shared.Avatar
import su.reya.coop.short
import su.reya.coop.viewmodel.AccountViewModel
import su.reya.coop.viewmodel.ChatViewModel
import kotlin.time.Duration.Companion.milliseconds
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun NewChatScreen() {
fun NewChatScreen(
accountViewModel: AccountViewModel,
chatViewModel: ChatViewModel
) {
val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current
val qrScanResult = LocalScanResult.current
val nostrViewModel = LocalNostrViewModel.current
val accountViewModel = LocalAccountViewModel.current
val chatViewModel = LocalChatViewModel.current
val contactList by accountViewModel.contactList.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") }
@@ -294,8 +293,8 @@ fun ReceiverChip(
pubkey: PublicKey,
onRemove: () -> Unit
) {
val nostrViewModel = LocalNostrViewModel.current
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
val profileCache = LocalProfileCache.current
val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) }
val profile by profileFlow.collectAsState(initial = null)
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
@@ -376,8 +375,8 @@ fun ContactListItem(
onClick: () -> Unit,
onLongClick: () -> Unit
) {
val nostrViewModel = LocalNostrViewModel.current
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
val profileCache = LocalProfileCache.current
val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) }
val profile by profileFlow.collectAsState(initial = null)
SegmentedListItem(

View File

@@ -3,16 +3,15 @@ package su.reya.coop.screens
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import su.reya.coop.LocalAccountViewModel
import su.reya.coop.LocalNavigator
import su.reya.coop.Screen
import su.reya.coop.shared.ProfileEditor
import su.reya.coop.viewmodel.AccountViewModel
@Composable
fun NewIdentityScreen() {
val accountViewModel = LocalAccountViewModel.current
fun NewIdentityScreen(viewModel: AccountViewModel) {
val navigator = LocalNavigator.current
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
val accountState by viewModel.state.collectAsStateWithLifecycle()
ProfileEditor(
title = "Create a new identity",
@@ -20,7 +19,7 @@ fun NewIdentityScreen() {
isBusy = accountState.isImporting,
onBack = { navigator.goBack() },
onConfirm = { name, bio, bytes, type ->
accountViewModel.createIdentity(name, bio, bytes, type)
viewModel.createIdentity(name, bio, bytes, type)
navigator.navigate(Screen.Home)
}
)

View File

@@ -43,26 +43,24 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.coop
import kotlinx.coroutines.launch
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalAccountViewModel
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen
import su.reya.coop.shared.getExpressiveFontFamily
import su.reya.coop.viewmodel.AccountViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun OnboardingScreen() {
fun OnboardingScreen(viewModel: AccountViewModel) {
val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current
val accountViewModel = LocalAccountViewModel.current
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
val accountState by viewModel.state.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
// Navigate to Home on successful external signer connection
@@ -176,10 +174,10 @@ fun OnboardingScreen() {
Spacer(modifier = Modifier.size(8.dp))
FilledTonalButton(
onClick = {
if (accountViewModel.isExternalSignerAvailable()) {
if (viewModel.isExternalSignerAvailable()) {
// Connect to the external signer
// TODO: show all available signers?
accountViewModel.connectExternalSigner()
viewModel.connectExternalSigner()
} else {
scope.launch {
val result = snackbarHostState.showSnackbar(

View File

@@ -44,28 +44,30 @@ import coop.composeapp.generated.resources.ic_share
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalChatViewModel
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalProfileCache
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen
import su.reya.coop.shared.Avatar
import su.reya.coop.shared.getExpressiveFontFamily
import su.reya.coop.short
import su.reya.coop.viewmodel.ChatViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ProfileScreen(pubkey: String) {
fun ProfileScreen(
pubkey: String,
chatViewModel: ChatViewModel
) {
val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current
val nostrViewModel = LocalNostrViewModel.current
val chatViewModel = LocalChatViewModel.current
val profileCache = LocalProfileCache.current
val scope = rememberCoroutineScope()
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) }
val profile by profileFlow.collectAsStateWithLifecycle()
val metadata = profile?.metadata?.asRecord()

View File

@@ -67,16 +67,13 @@ import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalAccountViewModel
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.viewmodel.AccountViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun RelayScreen() {
fun RelayScreen(viewModel: AccountViewModel) {
val navigator = LocalNavigator.current
val nostrViewModel = LocalNostrViewModel.current
val accountViewModel = LocalAccountViewModel.current
val snackbarHostState = LocalSnackbarHostState.current
val scope = rememberCoroutineScope()
@@ -99,12 +96,12 @@ fun RelayScreen() {
var relayToDelete by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
accountViewModel.loadCurrentUserRelayList()
accountViewModel.loadCurrentUserMsgRelayList()
viewModel.loadCurrentUserRelayList()
viewModel.loadCurrentUserMsgRelayList()
}
val loadedRelayList by accountViewModel.currentUserRelayList.collectAsStateWithLifecycle()
val loadedMsgRelayList by accountViewModel.currentUserMsgRelayList.collectAsStateWithLifecycle()
val loadedRelayList by viewModel.userRelayList.collectAsStateWithLifecycle()
val loadedMsgRelayList by viewModel.userMsgRelayList.collectAsStateWithLifecycle()
LaunchedEffect(loadedRelayList) {
if (loadedRelayList.isNotEmpty()) {
@@ -316,6 +313,7 @@ fun RelayScreen() {
if (openAddRelayDialog) {
AddRelayDialog(
viewModel = viewModel,
onDismissRequest = { openAddRelayDialog = false },
onMsgRelayAdded = { newRelay ->
msgRelayList.add(RelayUrl.parse(newRelay))
@@ -341,7 +339,7 @@ fun RelayScreen() {
relayToDelete = null
return@TextButton
}
accountViewModel.removeMsgRelay(relayToDelete!!)
viewModel.removeMsgRelay(relayToDelete!!)
msgRelayList.removeIf { it.toString() == relayToDelete }
relayToDelete = null
}
@@ -361,14 +359,12 @@ fun RelayScreen() {
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun AddRelayDialog(
viewModel: AccountViewModel,
onDismissRequest: () -> Unit,
onMsgRelayAdded: (newRelay: String) -> Unit,
onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit,
) {
val accountViewModel = LocalAccountViewModel.current
val snackbarHostState = LocalSnackbarHostState.current
val scope = rememberCoroutineScope()
val focusRequester = remember { FocusRequester() }
var relayAddress by remember { mutableStateOf("") }
@@ -416,17 +412,17 @@ fun AddRelayDialog(
if (!isError) {
when (selected) {
"Messaging" -> {
accountViewModel.addMsgRelay(relayAddress)
viewModel.addMsgRelay(relayAddress)
onMsgRelayAdded(relayAddress)
}
"Inbox" -> {
accountViewModel.addInboxRelay(relayAddress)
viewModel.addInboxRelay(relayAddress)
onRelayAdded(relayAddress, RelayMetadata.WRITE)
}
"Outbox" -> {
accountViewModel.addOutboxRelay(relayAddress)
viewModel.addOutboxRelay(relayAddress)
onRelayAdded(relayAddress, RelayMetadata.READ)
}
}

View File

@@ -27,7 +27,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -36,26 +35,23 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import kotlinx.coroutines.launch
import su.reya.coop.LocalChatViewModel
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.RoomKind
import su.reya.coop.Screen
import su.reya.coop.viewmodel.ChatViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
fun RequestListScreen() {
fun RequestListScreen(viewModel: ChatViewModel) {
val navigator = LocalNavigator.current
val snackbarHostState = LocalSnackbarHostState.current
val chatViewModel = LocalChatViewModel.current
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
val pullToRefreshState = rememberPullToRefreshState()
var isRefreshing by remember { mutableStateOf(false) }
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
// Get all request rooms
val requests = remember(chatRooms) {
@@ -102,7 +98,7 @@ fun RequestListScreen() {
state = pullToRefreshState,
onRefresh = {
isRefreshing = true
chatViewModel.refreshChatRooms()
viewModel.refreshChatRooms()
isRefreshing = false
},
indicator = {

View File

@@ -3,18 +3,16 @@ package su.reya.coop.screens
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import su.reya.coop.LocalAccountViewModel
import su.reya.coop.LocalNavigator
import su.reya.coop.shared.ProfileEditor
import su.reya.coop.viewmodel.AccountViewModel
@Composable
fun UpdateProfileScreen() {
val accountViewModel = LocalAccountViewModel.current
fun UpdateProfileScreen(viewModel: AccountViewModel) {
val navigator = LocalNavigator.current
val currentUser by accountViewModel.currentUserProfile.collectAsStateWithLifecycle()
val currentUser by viewModel.currentUserProfile.collectAsStateWithLifecycle()
val profile = currentUser?.metadata?.asRecord()
val isUpdatingProfile by accountViewModel.isUpdatingProfile.collectAsStateWithLifecycle()
val isUpdatingProfile by viewModel.isUpdatingProfile.collectAsStateWithLifecycle()
ProfileEditor(
title = "Update profile",
@@ -25,7 +23,7 @@ fun UpdateProfileScreen() {
isBusy = isUpdatingProfile,
onBack = { navigator.goBack() },
onConfirm = { name, bio, bytes, type ->
accountViewModel.updateProfile(name, bio, bytes, type)
viewModel.updateProfile(name, bio, bytes, type)
navigator.goBack()
}
)

View File

@@ -21,7 +21,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -31,36 +30,35 @@ import coop.composeapp.generated.resources.ic_check_circle
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.Timestamp
import su.reya.coop.LocalAccountViewModel
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalProfileCache
import su.reya.coop.Room
import su.reya.coop.humanReadable
import su.reya.coop.shared.Avatar
import su.reya.coop.shared.getExpressiveFontFamily
import su.reya.coop.short
import su.reya.coop.viewmodel.AccountViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ScreenerCard(room: Room) {
fun ScreenerCard(viewModel: AccountViewModel, room: Room) {
val pubkey = room.members.firstOrNull() ?: return
val nostrViewModel = LocalNostrViewModel.current
val accountViewModel = LocalAccountViewModel.current
val profileCache = LocalProfileCache.current
var isContact by remember { mutableStateOf(false) }
var mutualContacts by remember { mutableStateOf<Set<PublicKey>>(emptySet()) }
var lastActivity by remember { mutableStateOf<Timestamp?>(null) }
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) }
val profile by profileFlow.collectAsStateWithLifecycle()
LaunchedEffect(pubkey) {
// Check contact
accountViewModel.verifyContact(pubkey) { isContact = it }
viewModel.verifyContact(pubkey) { isContact = it }
// Get mutual contacts
accountViewModel.mutualContacts(pubkey) { mutualContacts = it }
viewModel.mutualContacts(pubkey) { mutualContacts = it }
// Get the last activity
accountViewModel.verifyActivity(pubkey) { lastActivity = it }
viewModel.verifyActivity(pubkey) { lastActivity = it }
}
Column(

View File

@@ -45,8 +45,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -64,40 +62,35 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.LocalAccountViewModel
import su.reya.coop.LocalChatViewModel
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalProfileCache
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Room
import su.reya.coop.RoomUiState
import su.reya.coop.Screen
import su.reya.coop.formatAsGroupHeader
import su.reya.coop.rememberUiState
import su.reya.coop.roomId
import su.reya.coop.shared.Avatar
import su.reya.coop.uiStateFlow
import su.reya.coop.viewmodel.AccountViewModel
import su.reya.coop.viewmodel.ChatScreenViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ChatScreen(
id: Long,
screening: Boolean = false,
viewModel: ChatScreenViewModel,
accountViewModel: AccountViewModel,
coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current
val nostrViewModel = LocalNostrViewModel.current
val chatViewModel = LocalChatViewModel.current
val profileCache = LocalProfileCache.current
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
// Get current user
val accountViewModel = LocalAccountViewModel.current
val currentUser by accountViewModel.currentUserProfile.collectAsStateWithLifecycle()
// Get chat room by ID
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
val id = viewModel.id
val currentUser by viewModel.currentUser.collectAsStateWithLifecycle()
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } }
// Show empty screen
@@ -115,13 +108,15 @@ fun ChatScreen(
return
}
val roomState by (room as Room).rememberUiState(nostrViewModel, currentUser?.publicKey)
var text by remember { mutableStateOf("") }
var loading by remember { mutableStateOf(true) }
var newOtherMessages by remember { mutableIntStateOf(0) }
var requireScreening by remember { mutableStateOf(screening) }
val roomState by (room as Room).uiStateFlow(profileCache, currentUser?.publicKey)
.collectAsStateWithLifecycle(RoomUiState())
val messages = remember { mutableStateListOf<UnsignedEvent>() }
var text by remember { mutableStateOf("") }
val loading = viewModel.loading
val newOtherMessages = viewModel.newOtherMessages
val requireScreening = viewModel.requireScreening
val messages = viewModel.messages
val groupedMessages =
remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroupHeader() } } }
@@ -136,7 +131,7 @@ fun ChatScreen(
val type = context.contentResolver.getType(uri)
// Send message (handles errors internally via ViewModel)
chatViewModel.sendFileMessage(id, file, type)
viewModel.sendFileMessage(file, type)
}
}
@@ -155,29 +150,6 @@ fun ChatScreen(
}
LaunchedEffect(id) {
// Get messages
chatViewModel.loadChatRoomMessages(id) { initialMessages ->
messages.clear()
messages.addAll(initialMessages)
// Stop loading spinner
loading = false
}
// Get msg relays for each member
chatViewModel.chatRoomConnect(id)
// Handle new messages
chatViewModel.newEvents.collect { event ->
if (event.roomId() == id) {
if (event.id() !in messages.map { it.id() }) {
messages.add(0, event)
}
} else {
// If the event is not in the current room, it's a new message from another user
newOtherMessages++
}
}
}
LaunchedEffect(messages.size) {
@@ -254,7 +226,7 @@ fun ChatScreen(
.padding(bottom = innerPadding.calculateBottomPadding())
) {
if (requireScreening) {
room?.let { ScreenerCard(it) }
room?.let { ScreenerCard(accountViewModel, it) }
}
val mineColor = MaterialTheme.colorScheme.onPrimaryContainer
@@ -338,7 +310,7 @@ fun ChatScreen(
)
}
FilledTonalButton(
onClick = { requireScreening = false },
onClick = { viewModel.requireScreening = false },
modifier = Modifier
.weight(1f)
.size(ButtonDefaults.MediumContainerHeight)
@@ -356,7 +328,7 @@ fun ChatScreen(
value = text,
onValueChange = { text = it },
onSend = {
chatViewModel.sendMessage(id, text)
viewModel.sendMessage(text)
text = ""
},
onUpload = {

View File

@@ -1,9 +1,5 @@
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
@@ -15,7 +11,7 @@ import kotlinx.datetime.toLocalDateTime
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.Timestamp
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.viewmodel.NostrViewModel
import su.reya.coop.viewmodel.ProfileCache
import kotlin.time.Clock
import kotlin.time.Instant
@@ -66,22 +62,6 @@ data class Room(
}
}
fun setKind(kind: RoomKind): Room {
return this.copy(kind = kind)
}
fun setCreatedAt(createdAt: Timestamp): Room {
return this.copy(createdAt = createdAt)
}
fun setSubject(subject: String?): Room {
return this.copy(subject = subject)
}
fun setLastMessage(message: String?): Room {
return this.copy(lastMessage = message)
}
fun isGroup(): Boolean = members.size > 1
}
@@ -92,7 +72,7 @@ data class RoomUiState(
)
fun Room.uiStateFlow(
nostrViewModel: NostrViewModel,
profileCache: ProfileCache,
currentUser: PublicKey? = null
): Flow<RoomUiState> {
val displayMembers = if (isGroup()) members.take(2) else members.take(1)
@@ -101,7 +81,7 @@ fun Room.uiStateFlow(
return flowOf(RoomUiState(name = subject.sanitizeName(), isGroup = isGroup()))
}
return combine(displayMembers.map { nostrViewModel.getMetadata(it) }) { profiles ->
return combine(displayMembers.map { profileCache.getMetadata(it) }) { profiles ->
val names = profiles.mapIndexed { i, profile ->
profile?.name?.sanitizeName() ?: displayMembers[i].short()
}
@@ -127,19 +107,6 @@ fun Room.uiStateFlow(
}
}
@Composable
fun Room.rememberUiState(
viewModel: NostrViewModel,
currentUser: PublicKey? = null
): State<RoomUiState> {
return remember(this, currentUser) {
uiStateFlow(
viewModel,
currentUser
)
}.collectAsStateWithLifecycle(RoomUiState())
}
fun UnsignedEvent.roomId(): Long {
// Collect the author's public key and all public keys from tags
val pubkeys: MutableList<PublicKey> = mutableListOf()

View File

@@ -37,18 +37,6 @@ import kotlin.time.Duration.Companion.seconds
object NostrManager {
val instance = Nostr()
val BOOTSTRAP_RELAYS = listOf(
"wss://relay.primal.net",
"wss://relay.ditto.pub",
"wss://user.kindpag.es",
)
val INDEXER_RELAY = listOf(
"wss://indexer.coracle.social",
)
val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY
}
class Nostr(

View File

@@ -32,6 +32,7 @@ import kotlin.time.Duration
class ProfileManager(private val nostr: Nostr) {
private val client: Client? get() = nostr.client
private val signer: UniversalSigner get() = nostr.signer
private val httpClient by lazy { HttpClient() }
private val _metadataUpdates =
MutableSharedFlow<Pair<PublicKey, Metadata>>(
@@ -88,7 +89,7 @@ class ProfileManager(private val nostr: Nostr) {
try {
val kind = Kind.fromStd(KindStandard.CONTACT_LIST)
val filter = Filter().kind(kind).authors(pubkeys).limit(pubkeys.size.toULong())
val relays = NostrManager.BOOTSTRAP_RELAYS.map { RelayUrl.parse(it) }
val relays = RelayManager.BOOTSTRAP_RELAYS.map { RelayUrl.parse(it) }
client?.sync(filter, relays)
} catch (e: Exception) {
@@ -226,7 +227,7 @@ class ProfileManager(private val nostr: Nostr) {
// Construct request target
val target = mutableMapOf<RelayUrl, List<Filter>>()
NostrManager.BOOTSTRAP_RELAYS.forEach { relay ->
RelayManager.BOOTSTRAP_RELAYS.forEach { relay ->
target[RelayUrl.parse(relay)] = listOf(filter)
}
@@ -265,7 +266,7 @@ class ProfileManager(private val nostr: Nostr) {
suspend fun searchByAddress(query: String): PublicKey {
try {
val address = Nip05Address.parse(query)
val profile = profileFromAddress(HttpClient(), address)
val profile = profileFromAddress(httpClient, address)
return profile.publicKey()
} catch (e: Exception) {
@@ -312,7 +313,7 @@ class ProfileManager(private val nostr: Nostr) {
try {
val filter = Filter().author(pubkey).limit(3u)
val target = mutableMapOf<RelayUrl, List<Filter>>()
NostrManager.BOOTSTRAP_RELAYS.forEach { relay ->
RelayManager.BOOTSTRAP_RELAYS.forEach { relay ->
target[RelayUrl.parse(relay)] = listOf(filter)
}

View File

@@ -20,14 +20,28 @@ import rust.nostr.sdk.nip17ExtractRelayList
import kotlin.time.Duration
class RelayManager(private val nostr: Nostr) {
companion object {
val BOOTSTRAP_RELAYS = listOf(
"wss://relay.primal.net",
"wss://relay.ditto.pub",
"wss://user.kindpag.es",
)
val INDEXER_RELAY = listOf(
"wss://indexer.coracle.social",
)
val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY
}
private val client: Client? get() = nostr.client
private val signer: UniversalSigner get() = nostr.signer
suspend fun connectBootstrapRelays() {
NostrManager.BOOTSTRAP_RELAYS.forEach { url ->
BOOTSTRAP_RELAYS.forEach { url ->
client?.addRelay(RelayUrl.parse(url))
}
NostrManager.INDEXER_RELAY.forEach { url ->
INDEXER_RELAY.forEach { url ->
client?.addRelay(
url = RelayUrl.parse(url),
capabilities = RelayCapabilities.gossip()
@@ -38,7 +52,7 @@ class RelayManager(private val nostr: Nostr) {
}
suspend fun reconnect() {
NostrManager.ALL_RELAYS.forEach { url ->
ALL_RELAYS.forEach { url ->
try {
client?.relay(RelayUrl.parse(url)).let { relay ->
if (relay != null) {
@@ -54,7 +68,7 @@ class RelayManager(private val nostr: Nostr) {
}
suspend fun disconnect() {
NostrManager.ALL_RELAYS.forEach { url ->
ALL_RELAYS.forEach { url ->
try {
client?.disconnectRelay(RelayUrl.parse(url))
} catch (e: Exception) {

View File

@@ -0,0 +1,597 @@
package su.reya.coop.repository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
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.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.EncryptedSecretKey
import rust.nostr.sdk.Keys
import rust.nostr.sdk.NostrConnect
import rust.nostr.sdk.NostrConnectUri
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.Timestamp
import su.reya.coop.AppStorage
import su.reya.coop.Profile
import su.reya.coop.nostr.ExternalSignerHandler
import su.reya.coop.nostr.ExternalSignerProxy
import su.reya.coop.nostr.Nostr
import su.reya.coop.nostr.SignerPermissions
import su.reya.coop.viewmodel.ErrorHost
import su.reya.coop.viewmodel.createErrorHost
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
data class AccountState(
val signerRequired: Boolean? = null,
val isNotificationBannerDismissed: Boolean = false,
val isImporting: Boolean = false,
val importError: String? = null,
)
class AccountRepository(
private val nostr: Nostr,
private val storage: AppStorage,
private val mediaRepository: MediaRepository,
private val scope: CoroutineScope,
private val externalSignerHandler: ExternalSignerHandler? = null,
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
) : ErrorHost by createErrorHost() {
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(AccountState())
val state: StateFlow<AccountState> = _state.asStateFlow()
private val _isUpdatingProfile = MutableStateFlow(false)
val isUpdatingProfile: StateFlow<Boolean> = _isUpdatingProfile.asStateFlow()
@OptIn(ExperimentalCoroutinesApi::class)
val currentUserProfile: StateFlow<Profile?> = nostr.signer.publicKeyFlow
.flatMapLatest { pubkey ->
if (pubkey != null) currentUserProfileFlow(pubkey) else flowOf(null)
}
.stateIn(scope, SharingStarted.WhileSubscribed(5000), null)
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
val contactList: StateFlow<Set<PublicKey>> = _contactList.asStateFlow()
private val _isRelayListEmpty = MutableStateFlow(false)
val isRelayListEmpty: StateFlow<Boolean> = _isRelayListEmpty.asStateFlow()
private val _userRelayList = MutableStateFlow<Map<RelayUrl, RelayMetadata?>>(emptyMap())
val userRelayList: StateFlow<Map<RelayUrl, RelayMetadata?>> = _userRelayList.asStateFlow()
private val _userMsgRelayList = MutableStateFlow<List<RelayUrl>>(emptyList())
val userMsgRelayList: StateFlow<List<RelayUrl>> = _userMsgRelayList.asStateFlow()
init {
checkNotificationBannerDismissedStatus()
login()
observeSignerState()
observeContactList()
}
private fun observeSignerState() {
scope.launch {
nostr.signer.publicKeyFlow
.filterNotNull()
.distinctUntilChanged()
.collectLatest { pubkey ->
// Refresh metadata
nostr.profiles.getUserMetadata()
// Verify messaging relays exist (wait up to 10s for initial fetch)
val relays = withTimeoutOrNull(10.seconds) {
var list = nostr.relays.getMsgRelays(pubkey)
while (list.isEmpty()) {
delay(500.milliseconds)
list = nostr.relays.getMsgRelays(pubkey)
}
list
} ?: emptyList()
// Automatically update the warning state
_isRelayListEmpty.value = relays.isEmpty()
}
}
}
private fun checkNotificationBannerDismissedStatus() {
scope.launch {
val dismissed = storage.get(KEY_BANNER_DISMISSED) == "true"
_state.update { it.copy(isNotificationBannerDismissed = dismissed) }
}
}
private fun login() {
scope.launch {
try {
val secret = withTimeoutOrNull(5.seconds) {
storage.getSecret(KEY_USER_SIGNER)
}
if (secret == null) {
_state.update { it.copy(signerRequired = true) }
return@launch
}
runCatching {
val (signer, _) = createSigner(secret)
nostr.setSigner(signer)
}.onSuccess {
_state.update { it.copy(signerRequired = false) }
}.onFailure { e ->
showError("Login failed: ${e.message}")
_state.update { it.copy(signerRequired = true) }
}
} catch (e: Exception) {
showError("Login failed: ${e.message}")
_state.update { it.copy(signerRequired = true) }
}
}
}
fun logout(onLogout: () -> Unit = {}) {
scope.launch {
try {
nostr.signer.switch(Keys.generate())
nostr.prune()
} catch (e: Exception) {
showError("Logout encountered an error: ${e.message}")
} finally {
storage.clear(KEY_USER_SIGNER)
storage.clear(KEY_BANNER_DISMISSED)
onLogout()
_state.update { it.copy(signerRequired = true) }
}
}
}
fun dismissNotificationBanner() {
scope.launch {
storage.set(KEY_BANNER_DISMISSED, "true")
_state.update { it.copy(isNotificationBannerDismissed = true) }
}
}
private suspend fun getOrInitAppKeys(): Keys = withContext(defaultDispatcher) {
val secret = storage.getSecret(KEY_APP_KEYS)
if (secret != null) return@withContext Keys.parse(secret)
val keys = Keys.generate()
storage.setSecret(KEY_APP_KEYS, keys.secretKey().toBech32())
keys
}
private suspend fun createSigner(
secret: String,
password: String? = null
): Pair<AsyncNostrSigner, String?> = withContext(defaultDispatcher) {
when {
secret.startsWith("nsec1") -> Keys.parse(secret) to null
secret.startsWith("ncryptsec1") -> {
if (password == null) throw IllegalArgumentException("Password is required")
val enc = EncryptedSecretKey.fromBech32(secret)
val decrypted = enc.decrypt(password)
val keys = Keys(decrypted)
keys to keys.secretKey().toBech32()
}
secret.startsWith("bunker://") -> {
val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret)
val timeout = 50.seconds
NostrConnect(uri = bunker, appKeys, timeout, null) to null
}
secret.startsWith("nip55://") -> {
val handler = externalSignerHandler ?: throw IllegalStateException("Not available")
val parts = secret.removePrefix("nip55://").split("/", limit = 2)
val packageName = parts[0]
val pubkey = PublicKey.parse(parts[1])
handler.setPackageName(packageName)
ExternalSignerProxy(handler, pubkey) to null
}
else -> throw IllegalArgumentException("Invalid secret format")
}
}
fun isExternalSignerAvailable(): Boolean {
return externalSignerHandler?.isAvailable() == true
}
fun importIdentity(secret: String, password: String? = null) {
scope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
try {
val (signer, decryptedSecret) = createSigner(secret, password)
nostr.setSigner(signer)
storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret)
_state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) {
showError("Import failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
}
}
}
fun connectExternalSigner() {
scope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
try {
val handler =
externalSignerHandler ?: throw IllegalStateException("Signer not available")
val permissions = SignerPermissions.toJson(
listOf(
SignerPermissions.signEvent(0),
SignerPermissions.signEvent(3),
SignerPermissions.signEvent(10000),
SignerPermissions.signEvent(10050),
SignerPermissions.signEvent(10063),
SignerPermissions.signEvent(22242),
SignerPermissions.signEvent(30030),
SignerPermissions.signEvent(30315),
SignerPermissions.nip44Encrypt(),
SignerPermissions.nip44Decrypt(),
)
)
val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected")
val signer = ExternalSignerProxy(handler, result.pubkey)
val uri = "nip55://${result.packageName}/${result.pubkey.toHex()}"
nostr.setSigner(signer)
storage.setSecret(KEY_USER_SIGNER, uri)
_state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) {
showError("External signer connection failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
}
}
}
fun createIdentity(
name: String,
bio: String?,
picture: ByteArray?,
contentType: String? = null
) {
scope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
try {
val keys = Keys.generate()
val secret = keys.secretKey().toBech32()
val avatarUrl = picture?.let {
mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg")
}
nostr.profiles.createIdentity(
keys = keys,
name = name,
bio = bio,
picture = avatarUrl
)
storage.setSecret(KEY_USER_SIGNER, secret)
_state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) {
showError("Identity creation failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun currentUserProfileFlow(pubkey: PublicKey) = merge(
flow {
nostr.waitUntilInitialized()
val cached = nostr.profiles.getAllCacheMetadata()[pubkey]
if (cached != null) emit(Profile(pubkey, cached))
nostr.profiles.fetchMetadataBatch(listOf(pubkey))
},
nostr.profiles.metadataUpdates
.filter { (p, _) -> p == pubkey }
.map { (p, m) -> Profile(p, m) }
)
fun updateProfile(
name: String? = null,
bio: String? = null,
picture: ByteArray? = null,
contentType: String? = null
) {
scope.launch {
_isUpdatingProfile.value = true
try {
val avatarUrl = picture?.let {
mediaRepository.blossomUpload(
nostr.signer.get(),
it,
contentType ?: "image/jpeg"
)
}
nostr.profiles.updateProfile(name, bio, avatarUrl)
_isUpdatingProfile.value = false
} catch (e: Exception) {
showError("Error: ${e.message}")
_isUpdatingProfile.value = false
}
}
}
private fun observeContactList() {
scope.launch {
nostr.waitUntilInitialized()
nostr.profiles.contactListUpdates.collect { contacts ->
_contactList.value = contacts.toSet()
}
}
}
fun resetInternalState() {
_contactList.value = emptySet()
_isRelayListEmpty.value = false
}
fun addContact(address: String) {
scope.launch {
val pubkey = try {
if (address.contains("@")) {
nostr.profiles.searchByAddress(address)
} else {
PublicKey.parse(address)
}
} catch (e: Exception) {
showError("Invalid contact address: ${e.message}")
return@launch
}
if (pubkey in _contactList.value) return@launch
try {
val updated = _contactList.value + pubkey
nostr.profiles.setContactList(updated.toList())
_contactList.update { it + pubkey }
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun removeContact(publicKey: PublicKey) {
scope.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) {
showError("Error: ${e.message}")
}
}
}
fun searchByAddress(query: String, onResult: (PublicKey?) -> Unit) {
scope.launch {
try {
onResult(nostr.profiles.searchByAddress(query))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(null)
}
}
}
fun searchByNostr(query: String, onResult: (List<PublicKey>) -> Unit) {
scope.launch {
try {
onResult(nostr.profiles.searchByNostr(query))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(emptyList())
}
}
}
fun verifyActivity(pubkey: PublicKey, onResult: (Timestamp?) -> Unit) {
scope.launch {
try {
onResult(nostr.profiles.verifyActivity(pubkey))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(null)
}
}
}
fun verifyContact(pubkey: PublicKey, onResult: (Boolean) -> Unit) {
scope.launch {
try {
onResult(nostr.profiles.verifyContact(pubkey))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(false)
}
}
}
fun mutualContacts(pubkey: PublicKey, onResult: (Set<PublicKey>) -> Unit) {
scope.launch {
try {
onResult(nostr.profiles.mutualContacts(pubkey))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(emptySet())
}
}
}
fun dismissRelayWarning() {
_isRelayListEmpty.value = false
}
fun refetchMsgRelays() {
scope.launch {
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@launch
val relays = nostr.relays.fetchMsgRelays(currentUser)
if (relays.isNotEmpty()) dismissRelayWarning()
}
}
fun useDefaultMsgRelayList() {
scope.launch {
try {
val defaultRelays = nostr.relays.getDefaultMsgRelayList()
nostr.relays.setMsgRelays(defaultRelays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun loadCurrentUserRelayList() {
scope.launch {
try {
val user = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
_userRelayList.value = nostr.relays.getRelayList(user)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
private suspend fun currentUserRelayListInternal(): 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()
}
}
fun addInboxRelay(relay: String) {
scope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayListInternal().toMutableMap()
relays[relayUrl] = RelayMetadata.WRITE
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun addOutboxRelay(relay: String) {
scope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayListInternal().toMutableMap()
relays[relayUrl] = RelayMetadata.READ
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun removeRelay(relay: String) {
scope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayListInternal().toMutableMap()
relays.remove(relayUrl)
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun loadCurrentUserMsgRelayList() {
scope.launch {
try {
val user = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
_userMsgRelayList.value = nostr.relays.getMsgRelays(user)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
private suspend fun userLatestMsgRelayList(): 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()
}
}
fun addMsgRelay(relay: String) {
scope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = userLatestMsgRelayList().toMutableSet()
relays.add(relayUrl)
nostr.relays.setMsgRelays(relays.toList())
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun removeMsgRelay(relay: String) {
scope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = userLatestMsgRelayList().toMutableSet()
relays.remove(relayUrl)
nostr.relays.setMsgRelays(relays.toList())
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
}

View File

@@ -0,0 +1,253 @@
package su.reya.coop.repository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow
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.Room
import su.reya.coop.nostr.Nostr
import su.reya.coop.roomId
import su.reya.coop.viewmodel.ErrorHost
import su.reya.coop.viewmodel.createErrorHost
data class ChatState(
val rooms: Map<Long, Room> = emptyMap(),
val isPartialProcessedGiftWrap: Boolean = false,
)
class ChatRepository(
private val nostr: Nostr,
private val mediaRepository: MediaRepository,
private val scope: CoroutineScope,
) : ErrorHost by createErrorHost() {
private val _state = MutableStateFlow(ChatState())
val state = _state.stateIn(
scope,
SharingStarted.WhileSubscribed(5000),
ChatState()
)
private val _newEvents = MutableSharedFlow<UnsignedEvent>(
replay = 0,
extraBufferCapacity = 100,
onBufferOverflow = BufferOverflow.SUSPEND
)
val newEvents = _newEvents.asSharedFlow()
val chatRooms = state.map { it.rooms.values.sortedByDescending { it.createdAt.asSecs() } }
.stateIn(scope, SharingStarted.WhileSubscribed(5000), emptyList())
val isSyncing = nostr.messages.messageSyncState.map { it.isSyncing }
.stateIn(scope, SharingStarted.WhileSubscribed(5000), false)
val isPartialProcessedGiftWrap = state.map { it.isPartialProcessedGiftWrap }
.stateIn(scope, SharingStarted.WhileSubscribed(5000), false)
init {
scope.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 || !syncState.isSyncing) {
_state.update { it.copy(isPartialProcessedGiftWrap = true) }
}
// Refresh UI every 100 messages OR when sync is fully done
if (syncState.processedCount % 100 == 0 || !syncState.isSyncing) {
refreshChatRooms()
}
}
}
// Observe new messages
launch {
nostr.newEvents.collect { event ->
val roomId = event.roomId()
val existingRoom = _state.value.rooms[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.id to newRoom)) }
} else {
updateRoomList(roomId, event)
}
_newEvents.tryEmit(event)
}
}
}
}
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[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.id to room)) }
return room.id
} catch (e: Exception) {
throw IllegalArgumentException("Failed to create room: ${e.message}")
}
}
fun getChatRoom(id: Long): Room? {
return _state.value.rooms[id]
}
fun refreshChatRooms() {
scope.launch {
try {
val rooms = nostr.messages.getChatRooms() ?: emptySet()
_state.update { currentState ->
val newMap = currentState.rooms.toMutableMap()
rooms.forEach { room -> newMap[room.id] = room }
currentState.copy(rooms = newMap)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun loadChatRoomMessages(roomId: Long, onResult: (List<UnsignedEvent>) -> Unit) {
scope.launch {
try {
onResult(nostr.messages.getChatRoomMessages(roomId))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun chatRoomConnect(roomId: Long) {
scope.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")
return
}
scope.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)
scope.launch { _newEvents.tryEmit(event) }
},
)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun sendFileMessage(
roomId: Long,
file: ByteArray?,
contentType: String? = "image/jpeg",
replies: List<EventId> = emptyList()
) {
if (file == null) return
scope.launch {
try {
val uri = mediaRepository.blossomUpload(nostr.signer.get(), file, contentType)
if (uri != null) sendMessage(roomId, uri, replies)
} catch (e: Exception) {
showError("File upload failed: ${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 room = currentState.rooms[roomId] ?: return@update currentState
val updatedRoom = room.copy(
lastMessage = newMessage.content(),
createdAt = newMessage.createdAt()
)
currentState.copy(rooms = currentState.rooms + (roomId to updatedRoom))
}
}
fun resetInternalState() {
_state.update {
it.copy(
rooms = emptyMap(),
isPartialProcessedGiftWrap = false,
)
}
}
}

View File

@@ -0,0 +1,73 @@
package su.reya.coop.viewmodel
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.StateFlow
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.repository.AccountRepository
import su.reya.coop.repository.AccountState
class AccountViewModel(
private val repository: AccountRepository,
) : ViewModel(), ErrorHost by repository {
val state: StateFlow<AccountState> = repository.state
val isUpdatingProfile: StateFlow<Boolean> = repository.isUpdatingProfile
val currentUserProfile: StateFlow<Profile?> = repository.currentUserProfile
val contactList: StateFlow<Set<PublicKey>> = repository.contactList
val isRelayListEmpty: StateFlow<Boolean> = repository.isRelayListEmpty
val userRelayList: StateFlow<Map<RelayUrl, RelayMetadata?>> = repository.userRelayList
val userMsgRelayList: StateFlow<List<RelayUrl>> = repository.userMsgRelayList
fun logout(onLogout: () -> Unit = {}) = repository.logout(onLogout)
fun dismissNotificationBanner() = repository.dismissNotificationBanner()
fun isExternalSignerAvailable() = repository.isExternalSignerAvailable()
fun importIdentity(secret: String, password: String? = null) =
repository.importIdentity(secret, password)
fun connectExternalSigner() = repository.connectExternalSigner()
fun createIdentity(
name: String,
bio: String?,
picture: ByteArray?,
contentType: String? = null
) = repository.createIdentity(name, bio, picture, contentType)
fun updateProfile(
name: String? = null,
bio: String? = null,
picture: ByteArray? = null,
contentType: String? = null
) = repository.updateProfile(name, bio, picture, contentType)
fun resetInternalState() = repository.resetInternalState()
fun addContact(address: String) = repository.addContact(address)
fun removeContact(publicKey: PublicKey) = repository.removeContact(publicKey)
fun searchByAddress(query: String, onResult: (PublicKey?) -> Unit) =
repository.searchByAddress(query, onResult)
fun searchByNostr(query: String, onResult: (List<PublicKey>) -> Unit) =
repository.searchByNostr(query, onResult)
fun verifyActivity(pubkey: PublicKey, onResult: (Timestamp?) -> Unit) =
repository.verifyActivity(pubkey, onResult)
fun verifyContact(pubkey: PublicKey, onResult: (Boolean) -> Unit) =
repository.verifyContact(pubkey, onResult)
fun mutualContacts(pubkey: PublicKey, onResult: (Set<PublicKey>) -> Unit) =
repository.mutualContacts(pubkey, onResult)
fun dismissRelayWarning() = repository.dismissRelayWarning()
fun refetchMsgRelays() = repository.refetchMsgRelays()
fun useDefaultMsgRelayList() = repository.useDefaultMsgRelayList()
fun loadCurrentUserRelayList() = repository.loadCurrentUserRelayList()
fun addInboxRelay(relay: String) = repository.addInboxRelay(relay)
fun addOutboxRelay(relay: String) = repository.addOutboxRelay(relay)
fun loadCurrentUserMsgRelayList() = repository.loadCurrentUserMsgRelayList()
fun addMsgRelay(relay: String) = repository.addMsgRelay(relay)
fun removeMsgRelay(relay: String) = repository.removeMsgRelay(relay)
}

View File

@@ -0,0 +1,72 @@
package su.reya.coop.viewmodel
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.Profile
import su.reya.coop.Room
import su.reya.coop.repository.AccountRepository
import su.reya.coop.repository.ChatRepository
import su.reya.coop.roomId
class ChatScreenViewModel(
val id: Long,
screening: Boolean,
accountRepository: AccountRepository,
private val chatRepository: ChatRepository,
) : ViewModel(), ErrorHost by chatRepository {
val currentUser: StateFlow<Profile?> = accountRepository.currentUserProfile
val chatRooms: StateFlow<List<Room>> = chatRepository.chatRooms
var loading by mutableStateOf(true)
var newOtherMessages by mutableIntStateOf(0)
var requireScreening by mutableStateOf(screening)
val messages = mutableStateListOf<UnsignedEvent>()
init {
loadMessages()
connect()
observeNewEvents()
}
private fun loadMessages() {
chatRepository.loadChatRoomMessages(id) { initialMessages ->
messages.clear()
messages.addAll(initialMessages)
loading = false
}
}
private fun connect() {
chatRepository.chatRoomConnect(id)
}
private fun observeNewEvents() {
viewModelScope.launch {
chatRepository.newEvents.collect { event ->
if (event.roomId() == id) {
if (event.id() !in messages.map { it.id() }) {
messages.add(0, event)
}
} else {
newOtherMessages++
}
}
}
}
fun sendMessage(text: String) {
chatRepository.sendMessage(id, text)
}
fun sendFileMessage(file: ByteArray?, type: String?) {
chatRepository.sendFileMessage(id, file, type)
}
}

View File

@@ -1,251 +1,21 @@
package su.reya.coop.viewmodel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow
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 androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.StateFlow
import rust.nostr.sdk.PublicKey
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: Map<Long, Room> = emptyMap(),
val isPartialProcessedGiftWrap: Boolean = false,
)
import su.reya.coop.repository.ChatRepository
import su.reya.coop.repository.ChatState
class ChatViewModel(
private val nostr: Nostr,
private val mediaRepository: MediaRepository,
) : BaseViewModel() {
private val _state = MutableStateFlow(ChatState())
val state = _state.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
ChatState()
)
private val repository: ChatRepository,
) : ViewModel(), ErrorHost by repository {
val state: StateFlow<ChatState> = repository.state
val chatRooms: StateFlow<List<Room>> = repository.chatRooms
val isSyncing: StateFlow<Boolean> = repository.isSyncing
val isPartialProcessedGiftWrap: StateFlow<Boolean> = repository.isPartialProcessedGiftWrap
private val _newEvents = MutableSharedFlow<UnsignedEvent>(
replay = 0,
extraBufferCapacity = 100,
onBufferOverflow = BufferOverflow.SUSPEND
)
val newEvents = _newEvents.asSharedFlow()
val chatRooms = state.map { it.rooms.values.sortedByDescending { it.createdAt.asSecs() } }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val isSyncing = nostr.messages.messageSyncState.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 || !syncState.isSyncing) {
_state.update { it.copy(isPartialProcessedGiftWrap = true) }
}
// Refresh UI every 100 messages OR when sync is fully done
if (syncState.processedCount % 100 == 0 || !syncState.isSyncing) {
refreshChatRooms()
}
}
}
// Observe new messages
launch {
nostr.newEvents.collect { event ->
val roomId = event.roomId()
val existingRoom = _state.value.rooms[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.id to newRoom)) }
} else {
updateRoomList(roomId, event)
}
_newEvents.tryEmit(event)
}
}
}
}
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[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.id to room)) }
return room.id
} catch (e: Exception) {
throw IllegalArgumentException("Failed to create room: ${e.message}")
}
}
fun getChatRoom(id: Long): Room? {
return _state.value.rooms[id]
}
fun refreshChatRooms() {
viewModelScope.launch {
try {
val rooms = nostr.messages.getChatRooms() ?: emptySet()
_state.update { currentState ->
val newMap = currentState.rooms.toMutableMap()
rooms.forEach { room -> newMap[room.id] = room }
currentState.copy(rooms = newMap)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun loadChatRoomMessages(roomId: Long, onResult: (List<UnsignedEvent>) -> Unit) {
viewModelScope.launch {
try {
onResult(nostr.messages.getChatRoomMessages(roomId))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
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")
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.tryEmit(event) }
},
)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun sendFileMessage(
roomId: Long,
file: ByteArray?,
contentType: String? = "image/jpeg",
replies: List<EventId> = emptyList()
) {
if (file == null) return
viewModelScope.launch {
try {
val uri = mediaRepository.blossomUpload(nostr.signer.get(), file, contentType)
if (uri != null) sendMessage(roomId, uri, replies)
} catch (e: Exception) {
showError("File upload failed: ${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 room = currentState.rooms[roomId] ?: return@update currentState
val updatedRoom = room.copy(
lastMessage = newMessage.content(),
createdAt = newMessage.createdAt()
)
currentState.copy(rooms = currentState.rooms + (roomId to updatedRoom))
}
}
fun resetInternalState() {
_state.update {
it.copy(
rooms = emptyMap(),
isPartialProcessedGiftWrap = false,
)
}
}
fun createChatRoom(to: List<PublicKey>): Long = repository.createChatRoom(to)
fun refreshChatRooms() = repository.refreshChatRooms()
fun resetInternalState() = repository.resetInternalState()
}

View File

@@ -1,19 +1,28 @@
package su.reya.coop.viewmodel
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
abstract class BaseViewModel : ViewModel() {
interface ErrorHost {
val errorEvents: SharedFlow<String>
fun showError(message: String)
}
fun createErrorHost(): ErrorHost = ErrorHostImpl()
private class ErrorHostImpl : ErrorHost {
private val _errorEvents = MutableSharedFlow<String>(
replay = 0,
extraBufferCapacity = 10,
onBufferOverflow = BufferOverflow.SUSPEND
)
val errorEvents = _errorEvents.asSharedFlow()
protected fun showError(message: String) {
override val errorEvents: SharedFlow<String> = _errorEvents.asSharedFlow()
override fun showError(message: String) {
_errorEvents.tryEmit(message)
}
}
}

View File

@@ -1,126 +0,0 @@
package su.reya.coop.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.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import rust.nostr.sdk.PublicKey
import su.reya.coop.Profile
import su.reya.coop.nostr.Nostr
import kotlin.time.Clock
import kotlin.time.Duration.Companion.milliseconds
class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
private val profilesMutex = Mutex()
private val profiles = mutableMapOf<PublicKey, MutableStateFlow<Profile?>>()
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
private val seenPublicKeys = mutableSetOf<PublicKey>()
init {
// Launch continuous background observers
viewModelScope.launch { runObserver() }
viewModelScope.launch { runMetadataBatching() }
// Automatically reconnect bootstrap relays
reconnect()
// Get all local stored metadata
getCacheMetadata()
}
private fun reconnect() {
viewModelScope.launch {
nostr.waitUntilInitialized()
nostr.reconnect()
}
}
private suspend fun runObserver() = coroutineScope {
// Observe metadata updates
launch {
nostr.profiles.metadataUpdates.collect { (pubkey, metadata) ->
updateMetadata(pubkey, Profile(pubkey, metadata))
}
}
}
private suspend fun runMetadataBatching() {
// Wait until the client is ready
nostr.waitUntilInitialized()
val batch = mutableSetOf<PublicKey>()
val timeout = 500L // 500ms timeout for batching
while (true) {
// Get the first pubkey
val firstKey = metadataRequestChannel.receive()
batch.add(firstKey)
// Get current time
val lastFlushTime = Clock.System.now().toEpochMilliseconds()
while (batch.isNotEmpty()) {
// Get the next pubkey
val nextKey = withTimeoutOrNull(timeout.milliseconds) {
metadataRequestChannel.receive()
}
// Only add the pubkey if it's not null
if (nextKey != null) batch.add(nextKey)
// Get current time
val now = Clock.System.now().toEpochMilliseconds()
// Check if the batch is full or timeout has passed
if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) {
val keysToRequest = batch.toList()
batch.clear()
nostr.profiles.fetchMetadataBatch(keysToRequest)
}
}
}
}
private fun getCacheMetadata() {
viewModelScope.launch {
// Wait until the client is ready
nostr.waitUntilInitialized()
val cache = nostr.profiles.getAllCacheMetadata()
profilesMutex.withLock {
cache.forEach { (pubkey, metadata) ->
val profile = Profile(pubkey, metadata)
profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile
seenPublicKeys.add(pubkey)
}
}
}
}
private fun requestMetadata(pubkey: PublicKey) {
if (seenPublicKeys.add(pubkey)) {
metadataRequestChannel.trySend(pubkey)
}
}
private suspend fun updateMetadata(pubkey: PublicKey, profile: Profile) {
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()
}
}

View File

@@ -0,0 +1,93 @@
package su.reya.coop.viewmodel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
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.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import rust.nostr.sdk.PublicKey
import su.reya.coop.Profile
import su.reya.coop.nostr.Nostr
import kotlin.time.Duration.Companion.milliseconds
class ProfileCache(
private val nostr: Nostr,
) : ErrorHost by createErrorHost() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val profilesMutex = Mutex()
private val profiles = mutableMapOf<PublicKey, MutableStateFlow<Profile?>>()
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
private val seenPublicKeys = mutableSetOf<PublicKey>()
init {
scope.launch { runObserver() }
scope.launch { runMetadataBatching() }
scope.launch {
nostr.waitUntilInitialized()
loadCacheMetadata()
}
}
private suspend fun runObserver() = coroutineScope {
launch {
nostr.profiles.metadataUpdates.collect { (pubkey, metadata) ->
updateMetadata(pubkey, Profile(pubkey, metadata))
}
}
}
private suspend fun runMetadataBatching() {
nostr.waitUntilInitialized()
while (true) {
val firstKey = metadataRequestChannel.receive()
val batch = mutableSetOf(firstKey)
while (batch.size < 10) {
val nextKey =
withTimeoutOrNull(500.milliseconds) { metadataRequestChannel.receive() }
?: break
batch.add(nextKey)
}
nostr.profiles.fetchMetadataBatch(batch.toList())
}
}
private suspend fun loadCacheMetadata() {
val cache = nostr.profiles.getAllCacheMetadata()
profilesMutex.withLock {
cache.forEach { (pubkey, metadata) ->
val profile = Profile(pubkey, metadata)
profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile
seenPublicKeys.add(pubkey)
}
}
}
private fun requestMetadata(pubkey: PublicKey) {
if (seenPublicKeys.add(pubkey)) {
metadataRequestChannel.trySend(pubkey)
}
}
private suspend fun updateMetadata(pubkey: PublicKey, profile: Profile) {
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()
}
}

View File

@@ -1,252 +0,0 @@
package su.reya.coop.viewmodel.account
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
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.withContext
import kotlinx.coroutines.withTimeoutOrNull
import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.EncryptedSecretKey
import rust.nostr.sdk.Keys
import rust.nostr.sdk.NostrConnect
import rust.nostr.sdk.NostrConnectUri
import rust.nostr.sdk.PublicKey
import su.reya.coop.AppStorage
import su.reya.coop.nostr.ExternalSignerHandler
import su.reya.coop.nostr.ExternalSignerProxy
import su.reya.coop.nostr.Nostr
import su.reya.coop.nostr.SignerPermissions
import su.reya.coop.repository.MediaRepository
import kotlin.time.Duration.Companion.seconds
data class AccountState(
val signerRequired: Boolean? = null,
val isNotificationBannerDismissed: Boolean = false,
val isImporting: Boolean = false,
val importError: String? = null,
)
class AccountAuthDelegate(
private val nostr: Nostr,
private val storage: AppStorage,
private val mediaRepository: MediaRepository,
private val externalSignerHandler: ExternalSignerHandler? = null,
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
private val onError: (String) -> Unit,
private val onSignerReady: () -> Unit,
private val scope: CoroutineScope,
) {
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(AccountState())
val state: StateFlow<AccountState> = _state.asStateFlow()
fun init() {
checkNotificationBannerDismissedStatus()
login()
}
private fun checkNotificationBannerDismissedStatus() {
scope.launch {
val dismissed = storage.get(KEY_BANNER_DISMISSED) == "true"
_state.update { it.copy(isNotificationBannerDismissed = dismissed) }
}
}
private fun login() {
scope.launch {
try {
val secret = withTimeoutOrNull(5.seconds) {
storage.getSecret(KEY_USER_SIGNER)
}
if (secret == null) {
_state.update { it.copy(signerRequired = true) }
return@launch
}
runCatching {
val (signer, _) = createSigner(secret)
nostr.setSigner(signer)
}.onSuccess {
_state.update { it.copy(signerRequired = false) }
onSignerReady()
}.onFailure { e ->
onError("Login failed: ${e.message}")
_state.update { it.copy(signerRequired = true) }
}
} catch (e: Exception) {
onError("Login failed: ${e.message}")
_state.update { it.copy(signerRequired = true) }
}
}
}
fun logout(onLogout: () -> Unit = {}) {
scope.launch {
try {
nostr.signer.switch(Keys.generate())
nostr.prune()
} catch (e: Exception) {
onError("Logout encountered an error: ${e.message}")
} finally {
storage.clear(KEY_USER_SIGNER)
storage.clear(KEY_BANNER_DISMISSED)
onLogout()
_state.update { it.copy(signerRequired = true) }
}
}
}
fun dismissNotificationBanner() {
scope.launch {
storage.set(KEY_BANNER_DISMISSED, "true")
_state.update { it.copy(isNotificationBannerDismissed = true) }
}
}
private suspend fun getOrInitAppKeys(): Keys = withContext(defaultDispatcher) {
val secret = storage.getSecret(KEY_APP_KEYS)
if (secret != null) return@withContext Keys.parse(secret)
val keys = Keys.generate()
storage.setSecret(KEY_APP_KEYS, keys.secretKey().toBech32())
keys
}
private suspend fun createSigner(
secret: String,
password: String? = null
): Pair<AsyncNostrSigner, String?> = withContext(defaultDispatcher) {
when {
secret.startsWith("nsec1") -> Keys.parse(secret) to null
secret.startsWith("ncryptsec1") -> {
if (password == null) throw IllegalArgumentException("Password is required")
val enc = EncryptedSecretKey.fromBech32(secret)
val decrypted = enc.decrypt(password)
val keys = Keys(decrypted)
keys to keys.secretKey().toBech32()
}
secret.startsWith("bunker://") -> {
val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret)
val timeout = 50.seconds
NostrConnect(uri = bunker, appKeys, timeout, null) to null
}
secret.startsWith("nip55://") -> {
val handler = externalSignerHandler ?: throw IllegalStateException("Not available")
val parts = secret.removePrefix("nip55://").split("/", limit = 2)
val packageName = parts[0]
val pubkey = PublicKey.parse(parts[1])
handler.setPackageName(packageName)
ExternalSignerProxy(handler, pubkey) to null
}
else -> throw IllegalArgumentException("Invalid secret format")
}
}
fun isExternalSignerAvailable(): Boolean {
return externalSignerHandler?.isAvailable() == true
}
fun importIdentity(secret: String, password: String? = null) {
scope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
try {
val (signer, decryptedSecret) = createSigner(secret, password)
nostr.setSigner(signer)
onSignerReady()
storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret)
_state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) {
onError("Import failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
}
}
}
fun connectExternalSigner() {
scope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
try {
val handler =
externalSignerHandler ?: throw IllegalStateException("Signer not available")
val permissions = SignerPermissions.toJson(
listOf(
SignerPermissions.signEvent(0),
SignerPermissions.signEvent(3),
SignerPermissions.signEvent(10000),
SignerPermissions.signEvent(10050),
SignerPermissions.signEvent(10063),
SignerPermissions.signEvent(22242),
SignerPermissions.signEvent(30030),
SignerPermissions.signEvent(30315),
SignerPermissions.nip44Encrypt(),
SignerPermissions.nip44Decrypt(),
)
)
val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected")
val signer = ExternalSignerProxy(handler, result.pubkey)
val uri = "nip55://${result.packageName}/${result.pubkey.toHex()}"
nostr.setSigner(signer)
onSignerReady()
storage.setSecret(KEY_USER_SIGNER, uri)
_state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) {
onError("External signer connection failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
}
}
}
fun createIdentity(
name: String,
bio: String?,
picture: ByteArray?,
contentType: String? = null
) {
scope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
try {
val keys = Keys.generate()
val secret = keys.secretKey().toBech32()
val avatarUrl = picture?.let {
mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg")
}
nostr.profiles.createIdentity(
keys = keys,
name = name,
bio = bio,
picture = avatarUrl
)
onSignerReady()
storage.setSecret(KEY_USER_SIGNER, secret)
_state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) {
onError("Identity creation failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
}
}
}
}

View File

@@ -1,137 +0,0 @@
package su.reya.coop.viewmodel.account
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 rust.nostr.sdk.PublicKey
import rust.nostr.sdk.Timestamp
import su.reya.coop.nostr.Nostr
class AccountContactDelegate(
private val nostr: Nostr,
private val scope: CoroutineScope,
private val onError: (String) -> Unit,
) {
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
val contactList: StateFlow<Set<PublicKey>> = _contactList.asStateFlow()
fun init() {
observeContactList()
}
fun reset() {
_contactList.value = emptySet()
}
private fun observeContactList() {
scope.launch {
nostr.waitUntilInitialized()
nostr.profiles.contactListUpdates.collect { contacts ->
_contactList.value = contacts.toSet()
}
}
}
private fun newContact(publicKey: PublicKey) {
if (publicKey in contactList.value) return
scope.launch {
try {
val updated = contactList.value + publicKey
nostr.profiles.setContactList(updated.toList())
_contactList.update { it + publicKey }
} catch (e: Exception) {
onError("Error: ${e.message}")
}
}
}
fun addContact(address: String) {
scope.launch {
val pubkey = try {
if (address.contains("@")) {
nostr.profiles.searchByAddress(address)
} else {
PublicKey.parse(address)
}
} catch (e: Exception) {
onError("Invalid contact address: ${e.message}")
return@launch
}
newContact(pubkey)
}
}
fun removeContact(publicKey: PublicKey) {
scope.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) {
onError("Error: ${e.message}")
}
}
}
fun searchByAddress(query: String, onResult: (PublicKey?) -> Unit) {
scope.launch {
try {
onResult(nostr.profiles.searchByAddress(query))
} catch (e: Exception) {
onError("Error: ${e.message}")
onResult(null)
}
}
}
fun searchByNostr(query: String, onResult: (List<PublicKey>) -> Unit) {
scope.launch {
try {
onResult(nostr.profiles.searchByNostr(query))
} catch (e: Exception) {
onError("Error: ${e.message}")
onResult(emptyList())
}
}
}
fun verifyActivity(pubkey: PublicKey, onResult: (Timestamp?) -> Unit) {
scope.launch {
try {
onResult(nostr.profiles.verifyActivity(pubkey))
} catch (e: Exception) {
onError("Error: ${e.message}")
onResult(null)
}
}
}
fun verifyContact(pubkey: PublicKey, onResult: (Boolean) -> Unit) {
scope.launch {
try {
onResult(nostr.profiles.verifyContact(pubkey))
} catch (e: Exception) {
onError("Error: ${e.message}")
onResult(false)
}
}
}
fun mutualContacts(pubkey: PublicKey, onResult: (Set<PublicKey>) -> Unit) {
scope.launch {
try {
onResult(nostr.profiles.mutualContacts(pubkey))
} catch (e: Exception) {
onError("Error: ${e.message}")
onResult(emptySet())
}
}
}
}

View File

@@ -1,81 +0,0 @@
package su.reya.coop.viewmodel.account
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import rust.nostr.sdk.PublicKey
import su.reya.coop.Profile
import su.reya.coop.nostr.Nostr
import su.reya.coop.repository.MediaRepository
class AccountProfileDelegate(
private val nostr: Nostr,
private val mediaRepository: MediaRepository,
private val onError: (String) -> Unit,
private val scope: CoroutineScope,
) {
private val _isUpdatingProfile = MutableStateFlow(false)
val isUpdatingProfile: StateFlow<Boolean> = _isUpdatingProfile.asStateFlow()
@OptIn(ExperimentalCoroutinesApi::class)
val currentUserProfile: StateFlow<Profile?> = nostr.signer.publicKeyFlow
.flatMapLatest { pubkey ->
if (pubkey != null) currentUserProfileFlow(pubkey) else flowOf(null)
}
.stateIn(scope, SharingStarted.WhileSubscribed(5000), null)
@OptIn(ExperimentalCoroutinesApi::class)
private fun currentUserProfileFlow(pubkey: PublicKey) = merge(
flow {
nostr.waitUntilInitialized()
val cached = nostr.profiles.getAllCacheMetadata()[pubkey]
if (cached != null) emit(Profile(pubkey, cached))
nostr.profiles.fetchMetadataBatch(listOf(pubkey))
},
nostr.profiles.metadataUpdates
.filter { (p, _) -> p == pubkey }
.map { (p, m) -> Profile(p, m) }
)
fun getUserMetadata() {
scope.launch {
nostr.profiles.getUserMetadata()
}
}
fun updateProfile(
name: String? = null,
bio: String? = null,
picture: ByteArray? = null,
contentType: String? = null
) {
scope.launch {
_isUpdatingProfile.value = true
try {
val avatarUrl = picture?.let {
mediaRepository.blossomUpload(
nostr.signer.get(),
it,
contentType ?: "image/jpeg"
)
}
nostr.profiles.updateProfile(name, bio, avatarUrl)
_isUpdatingProfile.value = false
} catch (e: Exception) {
onError("Error: ${e.message}")
_isUpdatingProfile.value = false
}
}
}
}

View File

@@ -1,185 +0,0 @@
package su.reya.coop.viewmodel.account
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl
import su.reya.coop.nostr.Nostr
import kotlin.time.Duration.Companion.seconds
class AccountRelayDelegate(
private val nostr: Nostr,
private val onError: (String) -> Unit,
private val scope: CoroutineScope,
) {
private val _isRelayListEmpty = MutableStateFlow(false)
val isRelayListEmpty = _isRelayListEmpty.asStateFlow()
private val _currentUserRelayList = MutableStateFlow<Map<RelayUrl, RelayMetadata?>>(emptyMap())
val currentUserRelayList = _currentUserRelayList.asStateFlow()
private val _currentUserMsgRelayList = MutableStateFlow<List<RelayUrl>>(emptyList())
val currentUserMsgRelayList = _currentUserMsgRelayList.asStateFlow()
fun reset() {
_isRelayListEmpty.value = false
}
@OptIn(ExperimentalCoroutinesApi::class)
fun checkRelayList() {
scope.launch {
val currentUser = nostr.signer.publicKeyFlow.filterNotNull().first()
println("user: ${currentUser.toBech32()}")
// Small delay to ensure subscription is ready
delay(6.seconds)
val relays = nostr.relays.getMsgRelays(currentUser)
if (relays.isEmpty()) _isRelayListEmpty.value = true
}
}
fun dismissRelayWarning() {
_isRelayListEmpty.value = false
}
fun refetchMsgRelays() {
scope.launch {
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@launch
val relays = nostr.relays.fetchMsgRelays(currentUser)
if (relays.isNotEmpty()) dismissRelayWarning()
}
}
fun useDefaultMsgRelayList() {
scope.launch {
try {
val defaultRelays = nostr.relays.getDefaultMsgRelayList()
nostr.relays.setMsgRelays(defaultRelays)
} catch (e: Exception) {
onError("Error: ${e.message}")
}
}
}
fun loadCurrentUserRelayList() {
scope.launch {
try {
val currentUser =
nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
_currentUserRelayList.value = nostr.relays.getRelayList(currentUser)
} catch (e: Exception) {
onError("Error: ${e.message}")
}
}
}
private suspend fun currentUserRelayListInternal(): Map<RelayUrl, RelayMetadata?> {
try {
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
return nostr.relays.getRelayList(currentUser)
} catch (e: Exception) {
onError("Error: ${e.message}")
return emptyMap()
}
}
fun addInboxRelay(relay: String) {
scope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayListInternal().toMutableMap()
relays[relayUrl] = RelayMetadata.WRITE
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
onError("Error: ${e.message}")
}
}
}
fun addOutboxRelay(relay: String) {
scope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayListInternal().toMutableMap()
relays[relayUrl] = RelayMetadata.READ
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
onError("Error: ${e.message}")
}
}
}
fun removeRelay(relay: String) {
scope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayListInternal().toMutableMap()
relays.remove(relayUrl)
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
onError("Error: ${e.message}")
}
}
}
fun loadCurrentUserMsgRelayList() {
scope.launch {
try {
val currentUser =
nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
_currentUserMsgRelayList.value = nostr.relays.getMsgRelays(currentUser)
} catch (e: Exception) {
onError("Error: ${e.message}")
}
}
}
private suspend fun currentUserMsgRelayListInternal(): List<RelayUrl> {
try {
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
return nostr.relays.getMsgRelays(currentUser)
} catch (e: Exception) {
onError("Error: ${e.message}")
return emptyList()
}
}
fun addMsgRelay(relay: String) {
scope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserMsgRelayListInternal().toMutableSet()
relays.add(relayUrl)
nostr.relays.setMsgRelays(relays.toList())
} catch (e: Exception) {
onError("Error: ${e.message}")
}
}
}
fun removeMsgRelay(relay: String) {
scope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserMsgRelayListInternal().toMutableSet()
relays.remove(relayUrl)
nostr.relays.setMsgRelays(relays.toList())
} catch (e: Exception) {
onError("Error: ${e.message}")
}
}
}
}

View File

@@ -1,146 +0,0 @@
package su.reya.coop.viewmodel.account
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.StateFlow
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.Timestamp
import su.reya.coop.AppStorage
import su.reya.coop.Profile
import su.reya.coop.nostr.ExternalSignerHandler
import su.reya.coop.nostr.Nostr
import su.reya.coop.repository.MediaRepository
import su.reya.coop.viewmodel.BaseViewModel
class AccountViewModel(
nostr: Nostr,
storage: AppStorage,
mediaRepository: MediaRepository,
externalSignerHandler: ExternalSignerHandler? = null,
defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
) : BaseViewModel() {
private val relays = AccountRelayDelegate(
nostr = nostr,
onError = ::showError,
scope = viewModelScope,
)
private val auth = AccountAuthDelegate(
nostr = nostr,
storage = storage,
mediaRepository = mediaRepository,
externalSignerHandler = externalSignerHandler,
defaultDispatcher = defaultDispatcher,
onError = ::showError,
onSignerReady = {
profile.getUserMetadata()
relays.checkRelayList()
},
scope = viewModelScope,
)
private val profile = AccountProfileDelegate(
nostr = nostr,
mediaRepository = mediaRepository,
onError = ::showError,
scope = viewModelScope,
)
private val contacts = AccountContactDelegate(
nostr = nostr,
onError = ::showError,
scope = viewModelScope,
)
val state = auth.state
val currentUserProfile: StateFlow<Profile?> = profile.currentUserProfile
val isUpdatingProfile: StateFlow<Boolean> = profile.isUpdatingProfile
val contactList: StateFlow<Set<PublicKey>> = contacts.contactList
val isRelayListEmpty: StateFlow<Boolean> = relays.isRelayListEmpty
val currentUserRelayList: StateFlow<Map<RelayUrl, RelayMetadata?>> = relays.currentUserRelayList
val currentUserMsgRelayList: StateFlow<List<RelayUrl>> = relays.currentUserMsgRelayList
init {
auth.init()
contacts.init()
}
fun logout(onLogout: () -> Unit = {}) = auth.logout(onLogout)
fun dismissNotificationBanner() = auth.dismissNotificationBanner()
fun connectExternalSigner() = auth.connectExternalSigner()
fun isExternalSignerAvailable(): Boolean = auth.isExternalSignerAvailable()
fun importIdentity(secret: String, password: String? = null) =
auth.importIdentity(secret, password)
fun createIdentity(
name: String,
bio: String? = null,
picture: ByteArray? = null,
contentType: String? = null,
) = auth.createIdentity(name, bio, picture, contentType)
fun updateProfile(
name: String? = null,
bio: String? = null,
picture: ByteArray? = null,
contentType: String? = null,
) {
profile.updateProfile(name, bio, picture, contentType)
}
fun resetInternalState() {
contacts.reset()
relays.reset()
}
fun addContact(address: String) = contacts.addContact(address)
fun removeContact(publicKey: PublicKey) = contacts.removeContact(publicKey)
fun searchByAddress(query: String, onResult: (PublicKey?) -> Unit) =
contacts.searchByAddress(query, onResult)
fun searchByNostr(query: String, onResult: (List<PublicKey>) -> Unit) =
contacts.searchByNostr(query, onResult)
fun verifyActivity(pubkey: PublicKey, onResult: (Timestamp?) -> Unit) =
contacts.verifyActivity(pubkey, onResult)
fun verifyContact(pubkey: PublicKey, onResult: (Boolean) -> Unit) =
contacts.verifyContact(pubkey, onResult)
fun mutualContacts(pubkey: PublicKey, onResult: (Set<PublicKey>) -> Unit) =
contacts.mutualContacts(pubkey, onResult)
fun dismissRelayWarning() = relays.dismissRelayWarning()
fun refetchMsgRelays() = relays.refetchMsgRelays()
fun useDefaultMsgRelayList() = relays.useDefaultMsgRelayList()
fun loadCurrentUserRelayList() = relays.loadCurrentUserRelayList()
fun addInboxRelay(relay: String) = relays.addInboxRelay(relay)
fun addOutboxRelay(relay: String) = relays.addOutboxRelay(relay)
fun loadCurrentUserMsgRelayList() = relays.loadCurrentUserMsgRelayList()
fun addMsgRelay(relay: String) = relays.addMsgRelay(relay)
fun removeMsgRelay(relay: String) = relays.removeMsgRelay(relay)
}