chore: refactor the internal structure #33

Merged
reya merged 9 commits from refactor-core into master 2026-07-03 01:20:37 +00:00
36 changed files with 1211 additions and 1139 deletions

View File

@@ -25,8 +25,8 @@ kotlin {
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3) implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.core.splashscreen)
implementation("su.reya:nostr-sdk-kmp:0.3.2") implementation("su.reya:nostr-sdk-kmp:0.3.2")
implementation("io.coil-kt.coil3:coil-compose:3.4.0") implementation("io.coil-kt.coil3:coil-compose:3.5.0")
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0") implementation("io.coil-kt.coil3:coil-network-okhttp:3.5.0")
implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0") implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0")
implementation("io.github.alexzhirkevich:qrose:1.1.2") implementation("io.github.alexzhirkevich:qrose:1.1.2")
} }

View File

@@ -34,6 +34,7 @@ import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import su.reya.coop.repository.ErrorRepository
import su.reya.coop.screens.ChatScreen import su.reya.coop.screens.ChatScreen
import su.reya.coop.screens.ContactListScreen import su.reya.coop.screens.ContactListScreen
import su.reya.coop.screens.HomeScreen import su.reya.coop.screens.HomeScreen
@@ -47,30 +48,20 @@ import su.reya.coop.screens.RelayScreen
import su.reya.coop.screens.RequestListScreen import su.reya.coop.screens.RequestListScreen
import su.reya.coop.screens.ScanScreen import su.reya.coop.screens.ScanScreen
import su.reya.coop.screens.UpdateProfileScreen import su.reya.coop.screens.UpdateProfileScreen
import su.reya.coop.viewmodels.AccountViewModel import su.reya.coop.viewmodel.AuthViewModel
import su.reya.coop.viewmodels.AppViewModel import su.reya.coop.viewmodel.ChatViewModel
import su.reya.coop.viewmodels.ChatViewModel import su.reya.coop.viewmodel.NostrViewModel
import su.reya.coop.viewmodels.ProfileViewModel
import su.reya.coop.viewmodels.RelayViewModel
val LocalAppViewModel = staticCompositionLocalOf<AppViewModel> { val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
error("No AppViewModel provided") error("No NostrViewModel provided")
}
val LocalAccountViewModel = staticCompositionLocalOf<AccountViewModel> {
error("No AccountViewModel provided")
} }
val LocalChatViewModel = staticCompositionLocalOf<ChatViewModel> { val LocalChatViewModel = staticCompositionLocalOf<ChatViewModel> {
error("No ChatViewModel provided") error("No ChatViewModel provided")
} }
val LocalProfileViewModel = staticCompositionLocalOf<ProfileViewModel> { val LocalAuthViewModel = staticCompositionLocalOf<AuthViewModel> {
error("No ProfileViewModel provided") error("No AuthViewModel provided")
}
val LocalRelayViewModel = staticCompositionLocalOf<RelayViewModel> {
error("No RelayViewModel provided")
} }
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> { val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
@@ -88,11 +79,9 @@ val LocalScanResult = staticCompositionLocalOf<QrScanResult> {
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun App( fun App(
appViewModel: AppViewModel, nostrViewModel: NostrViewModel,
accountViewModel: AccountViewModel,
chatViewModel: ChatViewModel, chatViewModel: ChatViewModel,
profileViewModel: ProfileViewModel, authViewModel: AuthViewModel,
relayViewModel: RelayViewModel,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val activity = context as? ComponentActivity val activity = context as? ComponentActivity
@@ -101,7 +90,8 @@ fun App(
val qrScanResult = remember { QrScanResult() } val qrScanResult = remember { QrScanResult() }
// Get the signer required state // Get the signer required state
val signerRequired by accountViewModel.signerRequired.collectAsStateWithLifecycle() val authState by authViewModel.state.collectAsStateWithLifecycle()
val signerRequired = authState.signerRequired
// Snackbar // Snackbar
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
@@ -128,7 +118,7 @@ fun App(
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
appViewModel.errorEvents.collect { message -> ErrorRepository.errors.collect { message ->
snackbarHostState.showSnackbar(message) snackbarHostState.showSnackbar(message)
} }
} }
@@ -171,15 +161,14 @@ fun App(
motionScheme = MotionScheme.expressive(), motionScheme = MotionScheme.expressive(),
) { ) {
CompositionLocalProvider( CompositionLocalProvider(
LocalAppViewModel provides appViewModel, LocalNostrViewModel provides nostrViewModel,
LocalAccountViewModel provides accountViewModel,
LocalChatViewModel provides chatViewModel, LocalChatViewModel provides chatViewModel,
LocalProfileViewModel provides profileViewModel, LocalAuthViewModel provides authViewModel,
LocalRelayViewModel provides relayViewModel,
LocalSnackbarHostState provides snackbarHostState, LocalSnackbarHostState provides snackbarHostState,
LocalNavigator provides navigator, LocalNavigator provides navigator,
LocalScanResult provides qrScanResult, LocalScanResult provides qrScanResult,
) { ) {
NavDisplay( NavDisplay(
backStack = backStack, backStack = backStack,
onBack = { onBack = {

View File

@@ -10,23 +10,13 @@ import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import su.reya.coop.coop.storage.SecretStore import su.reya.coop.coop.storage.SecretStore
import su.reya.coop.nostr.NostrManager import su.reya.coop.nostr.NostrManager
import su.reya.coop.viewmodels.AccountViewModel import su.reya.coop.viewmodel.AuthViewModel
import su.reya.coop.viewmodels.AppViewModel import su.reya.coop.viewmodel.ChatViewModel
import su.reya.coop.viewmodels.ChatViewModel import su.reya.coop.viewmodel.NostrViewModel
import su.reya.coop.viewmodels.ProfileViewModel
import su.reya.coop.viewmodels.RelayViewModel
import kotlin.system.exitProcess import kotlin.system.exitProcess
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -36,34 +26,30 @@ class MainActivity : ComponentActivity() {
private val factory by lazy { private val factory by lazy {
object : ViewModelProvider.Factory { object : ViewModelProvider.Factory {
private val secretStore = SecretStore(this@MainActivity)
private val androidSigner = private val androidSigner =
AndroidExternalSigner(this@MainActivity, externalSignerLauncher) AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
private val appVM = AppViewModel(NostrManager.instance, secretStore) private val secretStore = SecretStore(this@MainActivity)
private val profileVM = ProfileViewModel(NostrManager.instance, appVM) private val nostrViewModel =
private val chatVM = ChatViewModel(NostrManager.instance, appVM) NostrViewModel(NostrManager.instance)
private val accountVM = private val chatViewModel =
AccountViewModel(NostrManager.instance, secretStore, appVM, androidSigner) ChatViewModel(NostrManager.instance)
private val relayVM = RelayViewModel(NostrManager.instance, appVM, chatVM, profileVM) private val authViewModel =
AuthViewModel(NostrManager.instance, secretStore, androidSigner)
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
return when { return when {
modelClass.isAssignableFrom(AppViewModel::class.java) -> appVM modelClass.isAssignableFrom(NostrViewModel::class.java) -> nostrViewModel
modelClass.isAssignableFrom(AccountViewModel::class.java) -> accountVM modelClass.isAssignableFrom(ChatViewModel::class.java) -> chatViewModel
modelClass.isAssignableFrom(ChatViewModel::class.java) -> chatVM modelClass.isAssignableFrom(AuthViewModel::class.java) -> authViewModel
modelClass.isAssignableFrom(ProfileViewModel::class.java) -> profileVM
modelClass.isAssignableFrom(RelayViewModel::class.java) -> relayVM
else -> throw IllegalArgumentException("Unknown ViewModel class") else -> throw IllegalArgumentException("Unknown ViewModel class")
} as T } as T
} }
} }
} }
private val appViewModel: AppViewModel by viewModels { factory } private val nostrViewModel: NostrViewModel by viewModels { factory }
private val accountViewModel: AccountViewModel by viewModels { factory }
private val chatViewModel: ChatViewModel by viewModels { factory } private val chatViewModel: ChatViewModel by viewModels { factory }
private val profileViewModel: ProfileViewModel by viewModels { factory } private val authViewModel: AuthViewModel by viewModels { factory }
private val relayViewModel: RelayViewModel by viewModels { factory }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
@@ -102,36 +88,19 @@ class MainActivity : ComponentActivity() {
// Keep the splash screen visible until the signer check is complete // Keep the splash screen visible until the signer check is complete
splashScreen.setKeepOnScreenCondition { splashScreen.setKeepOnScreenCondition {
accountViewModel.signerRequired.value == null authViewModel.state.value.signerRequired == null
} }
// Bind the lifecycle of the ViewModels
val lifecycle = ProcessLifecycleOwner.get().lifecycle
lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
appViewModel.viewModelScope.launch {
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
coroutineScope {
launch { chatViewModel.bindObservers() }
launch { profileViewModel.bindObservers() }
}
}
}
}
})
relayViewModel.observeSignerAndCheckRelays()
setContent { setContent {
App( App(
appViewModel = appViewModel, nostrViewModel = nostrViewModel,
accountViewModel = accountViewModel,
chatViewModel = chatViewModel, chatViewModel = chatViewModel,
profileViewModel = profileViewModel, authViewModel = authViewModel,
relayViewModel = relayViewModel,
) )
} }
} }
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent) setIntent(intent)

View File

@@ -21,7 +21,6 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import su.reya.coop.nostr.NostrManager import su.reya.coop.nostr.NostrManager
import su.reya.coop.nostr.roomId
import java.io.File import java.io.File
private const val GROUP_KEY_MESSAGES = "su.reya.coop.MESSAGES" private const val GROUP_KEY_MESSAGES = "su.reya.coop.MESSAGES"

View File

@@ -70,27 +70,24 @@ import coop.composeapp.generated.resources.ic_cancel
import coop.composeapp.generated.resources.ic_check_circle import coop.composeapp.generated.resources.ic_check_circle
import coop.composeapp.generated.resources.ic_send import coop.composeapp.generated.resources.ic_send
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.Timestamp import rust.nostr.sdk.Timestamp
import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.LocalAccountViewModel
import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalChatViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalProfileViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Room
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.nostr.Room import su.reya.coop.formatAsGroupHeader
import su.reya.coop.nostr.formatAsGroupHeader import su.reya.coop.humanReadable
import su.reya.coop.nostr.humanReadable import su.reya.coop.rememberUiState
import su.reya.coop.nostr.roomId import su.reya.coop.roomId
import su.reya.coop.shared.Avatar import su.reya.coop.shared.Avatar
import su.reya.coop.shared.getExpressiveFontFamily import su.reya.coop.shared.getExpressiveFontFamily
import su.reya.coop.shared.nameFlow
import su.reya.coop.shared.pictureFlow
import su.reya.coop.short import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@@ -99,11 +96,14 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val nostrViewModel = LocalNostrViewModel.current
val chatViewModel = LocalChatViewModel.current val chatViewModel = LocalChatViewModel.current
val profileViewModel = LocalProfileViewModel.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val listState = rememberLazyListState() val listState = rememberLazyListState()
// Get current user
val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
// Get chat room by ID // Get chat room by ID
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle() val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } } val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } }
@@ -123,15 +123,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
return return
} }
val roomState by (room as Room).rememberUiState(nostrViewModel, currentUser?.publicKey)
val displayName by remember(room) {
room?.nameFlow(profileViewModel) ?: flowOf("Loading...")
}.collectAsStateWithLifecycle("Loading...")
val picture by remember(room) {
room?.pictureFlow(profileViewModel) ?: flowOf(null)
}.collectAsStateWithLifecycle(null)
var text by remember { mutableStateOf("") } var text by remember { mutableStateOf("") }
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
var newOtherMessages by remember { mutableIntStateOf(0) } var newOtherMessages by remember { mutableIntStateOf(0) }
@@ -215,14 +207,14 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
LoadingIndicator(modifier = Modifier.size(32.dp)) LoadingIndicator(modifier = Modifier.size(32.dp))
} else { } else {
Avatar( Avatar(
picture = picture, picture = roomState.picture,
description = displayName, description = roomState.name,
size = 32.dp, size = 32.dp,
) )
} }
Spacer(modifier = Modifier.size(8.dp)) Spacer(modifier = Modifier.size(8.dp))
Text( Text(
text = displayName, text = roomState.name,
style = MaterialTheme.typography.titleMediumEmphasized, style = MaterialTheme.typography.titleMediumEmphasized,
) )
} }
@@ -282,7 +274,10 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
items = messagesInGroup, items = messagesInGroup,
key = { it.id()?.toBech32() ?: it.hashCode() } key = { it.id()?.toBech32() ?: it.hashCode() }
) { ) {
ChatMessage(it) ChatMessage(
rumor = it,
isMine = currentUser?.publicKey == it.author()
)
} }
item { item {
DateSeparator(dateHeader) DateSeparator(dateHeader)
@@ -377,28 +372,24 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
fun ScreenerCard(room: Room) { fun ScreenerCard(room: Room) {
val pubkey = room.members.firstOrNull() ?: return val pubkey = room.members.firstOrNull() ?: return
val profileViewModel = LocalProfileViewModel.current val nostrViewModel = LocalNostrViewModel.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var isContact by remember { mutableStateOf(false) } var isContact by remember { mutableStateOf(false) }
var mutualContacts by remember { mutableStateOf<Set<PublicKey>>(emptySet()) } var mutualContacts by remember { mutableStateOf<Set<PublicKey>>(emptySet()) }
var lastActivity by remember { mutableStateOf<Timestamp?>(null) } var lastActivity by remember { mutableStateOf<Timestamp?>(null) }
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) } val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
val metadata by metadataFlow.collectAsStateWithLifecycle() val profile by profileFlow.collectAsStateWithLifecycle()
val profile = metadata?.asRecord()
val displayName = profile?.displayName ?: profile?.name ?: "No name"
val picture = profile?.picture
LaunchedEffect(pubkey) { LaunchedEffect(pubkey) {
scope.launch { scope.launch {
// Check contact // Check contact
profileViewModel.verifyContact(pubkey).let { isContact = it } nostrViewModel.verifyContact(pubkey).let { isContact = it }
// Get mutual contacts // Get mutual contacts
profileViewModel.mutualContacts(pubkey).let { mutualContacts = it } nostrViewModel.mutualContacts(pubkey).let { mutualContacts = it }
// Get the last activity // Get the last activity
profileViewModel.verifyActivity(pubkey)?.let { lastActivity = it } nostrViewModel.verifyActivity(pubkey)?.let { lastActivity = it }
} }
} }
@@ -415,7 +406,7 @@ fun ScreenerCard(room: Room) {
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Avatar( Avatar(
picture = picture, picture = profile?.picture,
description = "Profile picture", description = "Profile picture",
modifier = Modifier.size(120.dp), modifier = Modifier.size(120.dp),
shape = MaterialShapes.Cookie12Sided.toShape(), shape = MaterialShapes.Cookie12Sided.toShape(),
@@ -425,7 +416,7 @@ fun ScreenerCard(room: Room) {
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text( Text(
text = displayName, text = profile?.name ?: "No name",
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLargeEmphasized.copy( style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontFamily = getExpressiveFontFamily() fontFamily = getExpressiveFontFamily()
@@ -514,13 +505,9 @@ fun DateSeparator(date: String) {
@Composable @Composable
fun ChatMessage( fun ChatMessage(
rumor: UnsignedEvent rumor: UnsignedEvent,
isMine: Boolean = false,
) { ) {
val accountViewModel = LocalAccountViewModel.current
val chatViewModel = LocalChatViewModel.current
val currentUser = accountViewModel.nostr.signer.currentUser
val isMine = rumor.author() == currentUser
val bubbleShape = if (isMine) { val bubbleShape = if (isMine) {
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 4.dp) RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 4.dp)
} else { } else {
@@ -546,17 +533,7 @@ fun ChatMessage(
color = containerColor, color = containerColor,
contentColor = contentColor, contentColor = contentColor,
shape = bubbleShape, shape = bubbleShape,
modifier = Modifier modifier = Modifier.widthIn(max = 280.dp)
.widthIn(max = 280.dp)
.clickable(
onClick = {
val id = rumor.id()
if (id != null) {
val sent = chatViewModel.isMessageSent(id)
println("Sent: $sent")
}
}
)
) { ) {
Text( Text(
text = rumor.content(), text = rumor.content(),

View File

@@ -55,7 +55,7 @@ import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.Nip05Address import rust.nostr.sdk.Nip05Address
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalProfileViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.shared.Avatar import su.reya.coop.shared.Avatar
@@ -66,9 +66,9 @@ import su.reya.coop.short
fun ContactListScreen() { fun ContactListScreen() {
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val profileViewModel = LocalProfileViewModel.current val nostrViewModel = LocalNostrViewModel.current
val contactList by profileViewModel.contactList.collectAsStateWithLifecycle() val contactList by nostrViewModel.contactList.collectAsStateWithLifecycle()
var openAddContactDialog by remember { mutableStateOf(false) } var openAddContactDialog by remember { mutableStateOf(false) }
Scaffold( Scaffold(
@@ -180,7 +180,7 @@ fun ContactListScreen() {
@Composable @Composable
fun AddContactDialog(onDismissRequest: () -> Unit) { fun AddContactDialog(onDismissRequest: () -> Unit) {
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val profileViewModel = LocalProfileViewModel.current val nostrViewModel = LocalNostrViewModel.current
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
var contact by remember { mutableStateOf("") } var contact by remember { mutableStateOf("") }
var isError by remember { mutableStateOf(false) } var isError by remember { mutableStateOf(false) }
@@ -225,7 +225,7 @@ fun AddContactDialog(onDismissRequest: () -> Unit) {
actions = { actions = {
IconButton(onClick = { IconButton(onClick = {
scope.launch { scope.launch {
val success = profileViewModel.addContact(contact) val success = nostrViewModel.addContact(contact)
if (success) onDismissRequest() if (success) onDismissRequest()
} }
}) { }) {
@@ -283,32 +283,28 @@ fun ContactListItem(
total: Int = 0, total: Int = 0,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
val profileViewModel = LocalProfileViewModel.current val nostrViewModel = LocalNostrViewModel.current
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) } val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
val metadata by metadataFlow.collectAsState(initial = null) val profile by profileFlow.collectAsState(initial = null)
val profile = metadata?.asRecord()
val displayName = profile?.name ?: profile?.displayName ?: pubkey.short()
val picture = profile?.picture
SegmentedListItem( SegmentedListItem(
onClick = onClick, onClick = onClick,
onLongClick = { profileViewModel.removeContact(pubkey) }, onLongClick = { nostrViewModel.removeContact(pubkey) },
shapes = ListItemDefaults.segmentedShapes( shapes = ListItemDefaults.segmentedShapes(
index = index, index = index,
count = total count = total
), ),
leadingContent = { leadingContent = {
Avatar( Avatar(
picture = picture, picture = profile?.picture,
description = displayName, description = profile?.name,
size = 36.dp size = 36.dp
) )
}, },
supportingContent = { Text(text = pubkey.short()) }, supportingContent = { Text(text = pubkey.short()) },
content = { content = {
Text( Text(
text = displayName, text = profile?.name ?: "No name",
style = MaterialTheme.typography.titleMediumEmphasized, style = MaterialTheme.typography.titleMediumEmphasized,
) )
} }

View File

@@ -60,7 +60,6 @@ import androidx.compose.material3.rememberTooltipState
import androidx.compose.material3.toShape import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -87,27 +86,22 @@ import coop.composeapp.generated.resources.ic_new_chat
import coop.composeapp.generated.resources.ic_qr import coop.composeapp.generated.resources.ic_qr
import coop.composeapp.generated.resources.ic_request import coop.composeapp.generated.resources.ic_request
import coop.composeapp.generated.resources.ic_scanner import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalAccountViewModel import su.reya.coop.LocalAuthViewModel
import su.reya.coop.LocalAppViewModel
import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalChatViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalProfileViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalRelayViewModel
import su.reya.coop.LocalScanResult import su.reya.coop.LocalScanResult
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Room
import su.reya.coop.RoomKind
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.nostr.Room import su.reya.coop.ago
import su.reya.coop.nostr.RoomKind import su.reya.coop.rememberUiState
import su.reya.coop.nostr.ago
import su.reya.coop.shared.Avatar import su.reya.coop.shared.Avatar
import su.reya.coop.shared.getExpressiveFontFamily import su.reya.coop.shared.getExpressiveFontFamily
import su.reya.coop.shared.nameFlow
import su.reya.coop.shared.pictureFlow
import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -117,27 +111,24 @@ fun HomeScreen() {
val qrScanResult = LocalScanResult.current val qrScanResult = LocalScanResult.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val clipboardManager = LocalClipboard.current val clipboardManager = LocalClipboard.current
val appViewModel = LocalAppViewModel.current val nostrViewModel = LocalNostrViewModel.current
val accountViewModel = LocalAccountViewModel.current
val chatViewModel = LocalChatViewModel.current val chatViewModel = LocalChatViewModel.current
val profileViewModel = LocalProfileViewModel.current val authViewModel = LocalAuthViewModel.current
val relayViewModel = LocalRelayViewModel.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(true) val sheetState = rememberModalBottomSheetState(true)
val listState = rememberLazyListState() val listState = rememberLazyListState()
val pullToRefreshState = rememberPullToRefreshState() val pullToRefreshState = rememberPullToRefreshState()
val currentUser = accountViewModel.nostr.signer.currentUser ?: return val userProfile by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
val currentUserProfile = profileViewModel.getMetadata(currentUser)
val userProfile by currentUserProfile.collectAsStateWithLifecycle()
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle() val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
val isRelayListEmpty by relayViewModel.isRelayListEmpty.collectAsStateWithLifecycle()
val isRelayListEmpty by nostrViewModel.isRelayListEmpty.collectAsStateWithLifecycle()
val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle() val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle()
val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle() val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
val isBannerDismissed by appViewModel.isNotificationBannerDismissed.collectAsState()
val authState by authViewModel.state.collectAsStateWithLifecycle()
val isBannerDismissed = authState.isNotificationBannerDismissed
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } } val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
@@ -165,7 +156,7 @@ fun HomeScreen() {
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
chatViewModel.getChatRooms() chatViewModel.refreshChatRooms()
} }
LaunchedEffect(qrScanResult.content) { LaunchedEffect(qrScanResult.content) {
@@ -218,8 +209,8 @@ fun HomeScreen() {
// User // User
IconButton(onClick = { showBottomSheet = true }) { IconButton(onClick = { showBottomSheet = true }) {
Avatar( Avatar(
picture = userProfile?.asRecord()?.picture, picture = userProfile?.picture,
description = userProfile?.asRecord()?.displayName, description = userProfile?.name ?: "No name",
size = 32.dp, size = 32.dp,
) )
} }
@@ -289,7 +280,7 @@ fun HomeScreen() {
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
TextButton( TextButton(
onClick = { appViewModel.dismissNotificationBanner() }, onClick = { authViewModel.dismissNotificationBanner() },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) { ) {
Text(text = "Maybe later") Text(text = "Maybe later")
@@ -400,14 +391,6 @@ fun HomeScreen() {
onDismissRequest = { showBottomSheet = false }, onDismissRequest = { showBottomSheet = false },
sheetState = sheetState, sheetState = sheetState,
) { ) {
val pubkey = accountViewModel.nostr.signer.currentUser
val shortPubkey = pubkey?.short() ?: "Not available"
val userName =
userProfile?.asRecord()?.displayName
?: userProfile?.asRecord()?.name
?: "No name"
val dismissAndRun: (suspend () -> Unit) -> Unit = { action -> val dismissAndRun: (suspend () -> Unit) -> Unit = { action ->
scope.launch { scope.launch {
sheetState.hide() sheetState.hide()
@@ -432,8 +415,8 @@ fun HomeScreen() {
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Avatar( Avatar(
picture = userProfile?.asRecord()?.picture, picture = userProfile?.picture,
description = userProfile?.asRecord()?.displayName, description = userProfile?.name ?: "No name",
shape = MaterialShapes.Cookie9Sided.toShape(), shape = MaterialShapes.Cookie9Sided.toShape(),
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
@@ -443,7 +426,7 @@ fun HomeScreen() {
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = userName, text = userProfile?.name ?: "No name",
style = MaterialTheme.typography.titleLargeEmphasized, style = MaterialTheme.typography.titleLargeEmphasized,
) )
} }
@@ -454,16 +437,15 @@ fun HomeScreen() {
OutlinedButton( OutlinedButton(
onClick = { onClick = {
scope.launch { scope.launch {
pubkey?.let { userProfile?.publicKey?.let {
val bech32 = it.toBech32() val bech32 = it.toBech32()
val data = val data = ClipData.newPlainText(bech32, bech32)
ClipData.newPlainText(bech32, bech32)
clipboardManager.setClipEntry(ClipEntry(data)) clipboardManager.setClipEntry(ClipEntry(data))
} }
} }
}, },
) { ) {
Text(text = shortPubkey) Text(text = userProfile?.shortPublicKey ?: "Unknown")
} }
FilledIconButton( FilledIconButton(
onClick = { onClick = {
@@ -487,7 +469,7 @@ fun HomeScreen() {
// Show the relay setup dialog if the msg relay list is empty // Show the relay setup dialog if the msg relay list is empty
if (isRelayListEmpty) { if (isRelayListEmpty) {
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = { relayViewModel.dismissRelayWarning() }, onDismissRequest = { nostrViewModel.dismissRelayWarning() },
sheetState = sheetState, sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
) { ) {
@@ -594,7 +576,7 @@ fun HomeScreen() {
scope.launch { scope.launch {
isBusy = true isBusy = true
try { try {
relayViewModel.refetchMsgRelays(currentUser) nostrViewModel.refetchMsgRelays()
} catch (e: Exception) { } catch (e: Exception) {
snackbarHostState.showSnackbar("Failed to refresh metadata: ${e.message}") snackbarHostState.showSnackbar("Failed to refresh metadata: ${e.message}")
} }
@@ -614,7 +596,7 @@ fun HomeScreen() {
enabled = !isBusy, enabled = !isBusy,
onClick = { onClick = {
scope.launch { scope.launch {
relayViewModel.useDefaultMsgRelayList() nostrViewModel.useDefaultMsgRelayList()
sheetState.hide() sheetState.hide()
} }
}, },
@@ -637,34 +619,29 @@ fun HomeScreen() {
@Composable @Composable
fun NewRequests(requests: List<Room>) { fun NewRequests(requests: List<Room>) {
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val profileViewModel = LocalProfileViewModel.current val nostrViewModel = LocalNostrViewModel.current
val total = requests.size val total = requests.size
val firstRoom = requests.getOrNull(0) val firstRoom = requests.getOrNull(0)
val secondRoom = requests.getOrNull(1) val secondRoom = requests.getOrNull(1)
val firstName by remember(firstRoom) { val firstRoomState by (firstRoom as Room).rememberUiState(nostrViewModel)
firstRoom?.nameFlow(profileViewModel) ?: flowOf("") val secondRoomState by (secondRoom as Room).rememberUiState(nostrViewModel)
}.collectAsStateWithLifecycle("Loading...")
val secondName by remember(secondRoom) {
secondRoom?.nameFlow(profileViewModel) ?: flowOf("")
}.collectAsStateWithLifecycle("")
val supportingText = when { val supportingText = when {
total == 1 && firstRoom != null -> { total == 1 -> {
val message = firstRoom.lastMessage ?: "" val message = firstRoom.lastMessage ?: ""
"$firstName: $message" "${firstRoomState.name}: $message"
} }
total == 2 -> { total == 2 -> {
"$firstName and $secondName" "${firstRoomState.name} and ${secondRoomState.name}"
} }
total > 2 -> { total > 2 -> {
val othersCount = total - 2 val othersCount = total - 2
val othersText = if (othersCount == 1) "1 other" else "$othersCount others" val othersText = if (othersCount == 1) "1 other" else "$othersCount others"
"$firstName, $secondName and $othersText" "${firstRoomState.name}, ${secondRoomState.name} and $othersText"
} }
else -> "" else -> ""
@@ -720,14 +697,13 @@ fun NewRequests(requests: List<Room>) {
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun ChatRoom(room: Room, onClick: () -> Unit) { fun ChatRoom(room: Room, onClick: () -> Unit) {
val profileViewModel = LocalProfileViewModel.current val nostrViewModel = LocalNostrViewModel.current
val displayName by remember(room) { room.nameFlow(profileViewModel) }.collectAsStateWithLifecycle("Loading...") val roomState by room.rememberUiState(nostrViewModel)
val picture by remember(room) { room.pictureFlow(profileViewModel) }.collectAsStateWithLifecycle(null)
ListItem( ListItem(
modifier = Modifier.clickable(onClick = onClick), modifier = Modifier.clickable(onClick = onClick),
leadingContent = { leadingContent = {
Avatar(picture = picture, description = displayName) Avatar(picture = roomState.picture, description = roomState.picture)
}, },
headlineContent = { headlineContent = {
Row( Row(
@@ -735,7 +711,7 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = displayName, text = roomState.name,
style = MaterialTheme.typography.titleMediumEmphasized, style = MaterialTheme.typography.titleMediumEmphasized,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
@@ -768,7 +744,9 @@ fun BottomMenuList(
onDismiss: (suspend () -> Unit) -> Unit onDismiss: (suspend () -> Unit) -> Unit
) { ) {
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val accountViewModel = LocalAccountViewModel.current val nostrViewModel = LocalNostrViewModel.current
val chatViewModel = LocalChatViewModel.current
val authViewModel = LocalAuthViewModel.current
val defaultMenuList = listOf( val defaultMenuList = listOf(
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) }, "Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
@@ -798,7 +776,14 @@ fun BottomMenuList(
} }
Spacer(modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.size(16.dp))
FilledTonalButton( FilledTonalButton(
onClick = { onDismiss { accountViewModel.logout() } }, onClick = {
onDismiss {
authViewModel.logout(onLogout = {
nostrViewModel.resetInternalState()
chatViewModel.resetInternalState()
})
}
},
colors = ButtonDefaults.filledTonalButtonColors( colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.error, containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError contentColor = MaterialTheme.colorScheme.onError

View File

@@ -58,16 +58,14 @@ import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.Keys import rust.nostr.sdk.Keys
import rust.nostr.sdk.NostrConnectUri import rust.nostr.sdk.NostrConnectUri
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalAccountViewModel import su.reya.coop.LocalAuthViewModel
import su.reya.coop.LocalAppViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalProfileViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalScanResult import su.reya.coop.LocalScanResult
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.shared.Avatar import su.reya.coop.shared.Avatar
import su.reya.coop.shared.getExpressiveFontFamily import su.reya.coop.shared.getExpressiveFontFamily
import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
@@ -76,25 +74,20 @@ fun ImportScreen() {
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val qrScanResult = LocalScanResult.current val qrScanResult = LocalScanResult.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val appViewModel = LocalAppViewModel.current val nostrViewModel = LocalNostrViewModel.current
val accountViewModel = LocalAccountViewModel.current val authViewModel = LocalAuthViewModel.current
val profileViewModel = LocalProfileViewModel.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val isBusy by appViewModel.isBusy.collectAsStateWithLifecycle(false) val authState by authViewModel.state.collectAsStateWithLifecycle()
val isBusy = authState.isBusy
var secret by remember { mutableStateOf("") } var secret by remember { mutableStateOf("") }
var pubkey by remember { mutableStateOf<PublicKey?>(null) } var pubkey by remember { mutableStateOf<PublicKey?>(null) }
// Get metadata when pubkey changes val profile by remember(pubkey) {
val metadata by remember(pubkey) { pubkey?.let(nostrViewModel::getMetadata) ?: flowOf(null)
pubkey?.let(profileViewModel::getMetadata) ?: flowOf(null)
}.collectAsStateWithLifecycle(null) }.collectAsStateWithLifecycle(null)
val profile = metadata?.asRecord()
val displayName = profile?.displayName ?: profile?.name ?: pubkey?.short() ?: "Unknown"
val picture = profile?.picture
LaunchedEffect(qrScanResult.content) { LaunchedEffect(qrScanResult.content) {
qrScanResult.content?.let { result -> qrScanResult.content?.let { result ->
runCatching { runCatching {
@@ -168,7 +161,7 @@ fun ImportScreen() {
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Avatar( Avatar(
picture = picture, picture = profile?.picture,
description = "Profile picture", description = "Profile picture",
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
shape = MaterialShapes.Cookie9Sided.toShape(), shape = MaterialShapes.Cookie9Sided.toShape(),
@@ -176,7 +169,7 @@ fun ImportScreen() {
} }
Spacer(modifier = Modifier.size(8.dp)) Spacer(modifier = Modifier.size(8.dp))
Text( Text(
text = displayName, text = profile?.name ?: "",
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLargeEmphasized.copy( style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontFamily = getExpressiveFontFamily() fontFamily = getExpressiveFontFamily()
@@ -250,10 +243,10 @@ fun ImportScreen() {
onClick = { onClick = {
scope.launch { scope.launch {
if (pubkey == null) { if (pubkey == null) {
accountViewModel.verifyIdentity(secret).let { pubkey = it } authViewModel.verifyIdentity(secret).let { pubkey = it }
} else { } else {
// Import the identity // Import the identity
accountViewModel.importIdentity(secret) authViewModel.importIdentity(secret)
// Navigate to the home screen // Navigate to the home screen
navigator.navigate(Screen.Home) navigator.navigate(Screen.Home)
} }

View File

@@ -13,22 +13,24 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back import coop.composeapp.generated.resources.ic_arrow_back
import io.github.alexzhirkevich.qrose.rememberQrCodePainter import io.github.alexzhirkevich.qrose.rememberQrCodePainter
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalAccountViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
@Composable @Composable
fun MyQrScreen() { fun MyQrScreen() {
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val accountViewModel = LocalAccountViewModel.current val nostrViewModel = LocalNostrViewModel.current
val currentUser = accountViewModel.nostr.signer.currentUser ?: return val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
Scaffold( Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
@@ -59,7 +61,7 @@ fun MyQrScreen() {
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Image( Image(
painter = rememberQrCodePainter(currentUser.toBech32()), painter = rememberQrCodePainter(currentUser?.publicKey?.toBech32() ?: ""),
contentDescription = "My QR" contentDescription = "My QR"
) )
} }

View File

@@ -57,7 +57,7 @@ import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalChatViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalProfileViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalScanResult import su.reya.coop.LocalScanResult
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen import su.reya.coop.Screen
@@ -71,10 +71,10 @@ fun NewChatScreen() {
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val qrScanResult = LocalScanResult.current val qrScanResult = LocalScanResult.current
val nostrViewModel = LocalNostrViewModel.current
val chatViewModel = LocalChatViewModel.current val chatViewModel = LocalChatViewModel.current
val profileViewModel = LocalProfileViewModel.current
val contactList by profileViewModel.contactList.collectAsStateWithLifecycle() val contactList by nostrViewModel.contactList.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") } var query by remember { mutableStateOf("") }
val createGroup = remember { mutableStateOf(false) } val createGroup = remember { mutableStateOf(false) }
@@ -96,12 +96,12 @@ fun NewChatScreen() {
selectedReceivers.add(pubkey) selectedReceivers.add(pubkey)
} }
} else if (query.contains("@")) { } else if (query.contains("@")) {
val pubkey = profileViewModel.searchByAddress(query) val pubkey = nostrViewModel.searchByAddress(query)
if (pubkey != null) { if (pubkey != null) {
selectedReceivers.add(pubkey) selectedReceivers.add(pubkey)
} }
} else { } else {
val results = profileViewModel.searchByNostr(query) val results = nostrViewModel.searchByNostr(query)
searchResults.clear() searchResults.clear()
searchResults.addAll(results) searchResults.addAll(results)
} }
@@ -290,13 +290,9 @@ fun ReceiverChip(
pubkey: PublicKey, pubkey: PublicKey,
onRemove: () -> Unit onRemove: () -> Unit
) { ) {
val profileViewModel = LocalProfileViewModel.current val nostrViewModel = LocalNostrViewModel.current
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) } val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
val metadata by metadataFlow.collectAsState(initial = null) val profile by profileFlow.collectAsState(initial = null)
val profile = metadata?.asRecord()
val displayName = profile?.name ?: profile?.displayName ?: pubkey.short()
val picture = profile?.picture
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) { CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
InputChip( InputChip(
@@ -304,7 +300,7 @@ fun ReceiverChip(
onClick = onRemove, onClick = onRemove,
label = { label = {
Text( Text(
text = displayName, text = profile?.name ?: "No name",
style = MaterialTheme.typography.bodyMedium.copy( style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.SemiBold fontWeight = FontWeight.SemiBold
) )
@@ -312,8 +308,8 @@ fun ReceiverChip(
}, },
avatar = { avatar = {
Avatar( Avatar(
picture = picture, picture = profile?.picture,
description = displayName, description = profile?.name ?: "No name",
size = 24.dp size = 24.dp
) )
}, },
@@ -376,13 +372,9 @@ fun ContactListItem(
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit onLongClick: () -> Unit
) { ) {
val profileViewModel = LocalProfileViewModel.current val nostrViewModel = LocalNostrViewModel.current
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) } val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
val metadata by metadataFlow.collectAsState(initial = null) val profile by profileFlow.collectAsState(initial = null)
val profile = metadata?.asRecord()
val displayName = profile?.name ?: profile?.displayName ?: pubkey.short()
val picture = profile?.picture
SegmentedListItem( SegmentedListItem(
selected = isSelected, selected = isSelected,
@@ -394,15 +386,15 @@ fun ContactListItem(
), ),
leadingContent = { leadingContent = {
Avatar( Avatar(
picture = picture, picture = profile?.picture,
description = displayName, description = profile?.name ?: "",
size = 36.dp size = 36.dp
) )
}, },
supportingContent = { Text(text = pubkey.short()) }, supportingContent = { Text(text = pubkey.short()) },
content = { content = {
Text( Text(
text = displayName, text = profile?.name ?: "",
style = MaterialTheme.typography.titleMediumEmphasized, style = MaterialTheme.typography.titleMediumEmphasized,
) )
} }

View File

@@ -5,21 +5,19 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import su.reya.coop.LocalAccountViewModel import su.reya.coop.LocalAuthViewModel
import su.reya.coop.LocalAppViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalProfileViewModel
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.shared.ProfileEditor import su.reya.coop.shared.ProfileEditor
@Composable @Composable
fun NewIdentityScreen() { fun NewIdentityScreen() {
val appViewModel = LocalAppViewModel.current val authViewModel = LocalAuthViewModel.current
val accountViewModel = LocalAccountViewModel.current
val profileViewModel = LocalProfileViewModel.current
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val isBusy by appViewModel.isBusy.collectAsStateWithLifecycle(false)
val authState by authViewModel.state.collectAsStateWithLifecycle()
val isBusy = authState.isBusy
ProfileEditor( ProfileEditor(
title = "Create a new identity", title = "Create a new identity",
@@ -28,7 +26,7 @@ fun NewIdentityScreen() {
onBack = { navigator.goBack() }, onBack = { navigator.goBack() },
onConfirm = { name, bio, bytes, type -> onConfirm = { name, bio, bytes, type ->
scope.launch { scope.launch {
accountViewModel.createIdentity(name, bio, bytes, type) authViewModel.createIdentity(name, bio, bytes, type)
navigator.navigate(Screen.Home) navigator.navigate(Screen.Home)
} }
} }

View File

@@ -45,7 +45,7 @@ import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.coop import coop.composeapp.generated.resources.coop
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalAccountViewModel import su.reya.coop.LocalAuthViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen import su.reya.coop.Screen
@@ -57,7 +57,7 @@ fun OnboardingScreen() {
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val accountViewModel = LocalAccountViewModel.current val authViewModel = LocalAuthViewModel.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val logoPainter = painterResource(Res.drawable.coop) val logoPainter = painterResource(Res.drawable.coop)
@@ -158,9 +158,9 @@ fun OnboardingScreen() {
FilledTonalButton( FilledTonalButton(
onClick = { onClick = {
scope.launch { scope.launch {
if (accountViewModel.isExternalSignerAvailable()) { if (authViewModel.isExternalSignerAvailable()) {
try { try {
accountViewModel.connectExternalSigner() authViewModel.connectExternalSigner()
navigator.navigate(Screen.Home) navigator.navigate(Screen.Home)
} catch (e: Exception) { } catch (e: Exception) {
e.message?.let { snackbarHostState.showSnackbar(it) } e.message?.let { snackbarHostState.showSnackbar(it) }

View File

@@ -46,7 +46,7 @@ import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalChatViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalProfileViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.shared.Avatar import su.reya.coop.shared.Avatar
@@ -56,28 +56,27 @@ import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun ProfileScreen(pubkey: String) { fun ProfileScreen(pubkey: String) {
val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val nostrViewModel = LocalNostrViewModel.current
val chatViewModel = LocalChatViewModel.current val chatViewModel = LocalChatViewModel.current
val profileViewModel = LocalProfileViewModel.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
val profile by profileFlow.collectAsStateWithLifecycle()
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) } val metadata = profile?.metadata?.asRecord()
val metadata by metadataFlow.collectAsStateWithLifecycle() val nip05 = metadata?.nip05 ?: pubkey.short()
val picture = metadata?.picture
val profile = metadata?.asRecord()
val displayName = profile?.displayName ?: profile?.name ?: "No name"
val nip05 = profile?.nip05 ?: pubkey.short()
val picture = profile?.picture
val details = remember(profile) { val details = remember(profile) {
listOf( listOf(
"Username:" to (profile?.name ?: "None"), "Username:" to (metadata?.name ?: "None"),
"Website:" to (profile?.website ?: "None"), "Website:" to (metadata?.website ?: "None"),
"₿ Lightning Address:" to (profile?.lud16 ?: "None"), "₿ Lightning Address:" to (metadata?.lud16 ?: "None"),
) )
} }
@@ -135,7 +134,7 @@ fun ProfileScreen(pubkey: String) {
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
Text( Text(
text = displayName, text = profile?.name ?: "No name",
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLargeEmphasized.copy( style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontFamily = getExpressiveFontFamily() fontFamily = getExpressiveFontFamily()
@@ -161,7 +160,8 @@ fun ProfileScreen(pubkey: String) {
onClick = { onClick = {
scope.launch { scope.launch {
try { try {
val roomId = chatViewModel.createChatRoom(listOf(pubkey)) val roomId =
chatViewModel.createChatRoom(listOf(pubkey))
navigator.navigate(Screen.Chat(roomId)) navigator.navigate(Screen.Chat(roomId))
} catch (e: Exception) { } catch (e: Exception) {
e.message?.let { snackbarHostState.showSnackbar(it) } e.message?.let { snackbarHostState.showSnackbar(it) }

View File

@@ -66,7 +66,7 @@ import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.RelayMetadata import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl import rust.nostr.sdk.RelayUrl
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalRelayViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@@ -74,7 +74,7 @@ import su.reya.coop.LocalSnackbarHostState
fun RelayScreen() { fun RelayScreen() {
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val relayViewModel = LocalRelayViewModel.current val nostrViewModel = LocalNostrViewModel.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val msgRelayList = remember { mutableStateListOf<RelayUrl>() } val msgRelayList = remember { mutableStateListOf<RelayUrl>() }
@@ -96,8 +96,8 @@ fun RelayScreen() {
var relayToDelete by remember { mutableStateOf<String?>(null) } var relayToDelete by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
relayList.putAll(relayViewModel.currentUserRelayList()) relayList.putAll(nostrViewModel.currentUserRelayList())
msgRelayList.addAll(relayViewModel.currentUserMsgRelayList()) msgRelayList.addAll(nostrViewModel.currentUserMsgRelayList())
} }
Scaffold( Scaffold(
@@ -321,7 +321,7 @@ fun RelayScreen() {
return@launch return@launch
} }
try { try {
relayViewModel.removeMsgRelay(relayToDelete!!) nostrViewModel.removeMsgRelay(relayToDelete!!)
msgRelayList.removeIf { it.toString() == relayToDelete } msgRelayList.removeIf { it.toString() == relayToDelete }
relayToDelete = null relayToDelete = null
} catch (e: Exception) { } catch (e: Exception) {
@@ -349,7 +349,7 @@ fun AddRelayDialog(
onMsgRelayAdded: (newRelay: String) -> Unit, onMsgRelayAdded: (newRelay: String) -> Unit,
onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit, onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit,
) { ) {
val relayViewModel = LocalRelayViewModel.current val nostrViewModel = LocalNostrViewModel.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -401,17 +401,17 @@ fun AddRelayDialog(
if (!isError) { if (!isError) {
when (selected) { when (selected) {
"Messaging" -> { "Messaging" -> {
relayViewModel.addMsgRelay(relayAddress) nostrViewModel.addMsgRelay(relayAddress)
onMsgRelayAdded(relayAddress) onMsgRelayAdded(relayAddress)
} }
"Inbox" -> { "Inbox" -> {
relayViewModel.addInboxRelay(relayAddress) nostrViewModel.addInboxRelay(relayAddress)
onRelayAdded(relayAddress, RelayMetadata.WRITE) onRelayAdded(relayAddress, RelayMetadata.WRITE)
} }
"Outbox" -> { "Outbox" -> {
relayViewModel.addOutboxRelay(relayAddress) nostrViewModel.addOutboxRelay(relayAddress)
onRelayAdded(relayAddress, RelayMetadata.READ) onRelayAdded(relayAddress, RelayMetadata.READ)
} }
} }

View File

@@ -40,8 +40,8 @@ import kotlinx.coroutines.launch
import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalChatViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.RoomKind
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.nostr.RoomKind
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable

View File

@@ -5,25 +5,19 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import su.reya.coop.LocalAccountViewModel
import su.reya.coop.LocalAppViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalProfileViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.shared.ProfileEditor import su.reya.coop.shared.ProfileEditor
@Composable @Composable
fun UpdateProfileScreen() { fun UpdateProfileScreen() {
val appViewModel = LocalAppViewModel.current val nostrViewModel = LocalNostrViewModel.current
val accountViewModel = LocalAccountViewModel.current
val profileViewModel = LocalProfileViewModel.current
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val currentUser = accountViewModel.nostr.signer.currentUser ?: return val isBusy by nostrViewModel.isBusy.collectAsStateWithLifecycle(false)
val metadata by profileViewModel.getMetadata(currentUser).collectAsStateWithLifecycle() val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
val isBusy by appViewModel.isBusy.collectAsStateWithLifecycle(false) val profile = currentUser?.metadata?.asRecord()
val profile = metadata?.asRecord()
ProfileEditor( ProfileEditor(
title = "Update profile", title = "Update profile",
@@ -35,7 +29,7 @@ fun UpdateProfileScreen() {
onBack = { navigator.goBack() }, onBack = { navigator.goBack() },
onConfirm = { name, bio, bytes, type -> onConfirm = { name, bio, bytes, type ->
scope.launch { scope.launch {
profileViewModel.updateProfile(name, bio, bytes, type) nostrViewModel.updateProfile(name, bio, bytes, type)
navigator.goBack() navigator.goBack()
} }
} }

View File

@@ -1,44 +0,0 @@
package su.reya.coop.shared
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import rust.nostr.sdk.PublicKey
import su.reya.coop.nostr.Room
import su.reya.coop.short
import su.reya.coop.viewmodels.ProfileViewModel
fun Room.nameFlow(
profileViewModel: ProfileViewModel,
currentUser: PublicKey? = null
): Flow<String> {
// Return early if there's a custom subject/room name
subject?.takeIf { it.isNotBlank() }?.let { return flowOf(it) }
val displayMembers = if (isGroup()) members.take(2) else members.take(1)
if (displayMembers.isEmpty()) return flowOf("Unknown")
return combine(displayMembers.map { profileViewModel.getMetadata(it) }) { metadataArray ->
val names = metadataArray.mapIndexed { i, metadata ->
val profile = metadata?.asRecord()
profile?.displayName?.takeIf { it.isNotBlank() }
?: profile?.name?.takeIf { it.isNotBlank() }
?: displayMembers[i].short()
}
if (isGroup()) {
val combined = names.joinToString(", ")
val extraCount = members.size - names.size
if (extraCount > 0) "$combined, +$extraCount" else combined
} else {
val name = names.first()
if (displayMembers.first() == currentUser) "$name (you)" else name
}
}
}
fun Room.pictureFlow(profileViewModel: ProfileViewModel): Flow<String?> {
val firstMember = members.firstOrNull() ?: return flowOf(null)
return profileViewModel.getMetadata(firstMember).map { it?.asRecord()?.picture }
}

View File

@@ -10,14 +10,14 @@ androidx-espresso = "3.7.0"
androidx-lifecycle = "2.10.0" androidx-lifecycle = "2.10.0"
androidx-testExt = "1.3.0" androidx-testExt = "1.3.0"
androidx-splashscreen = "1.2.0" androidx-splashscreen = "1.2.0"
composeMultiplatform = "1.11.0" composeMultiplatform = "1.11.1"
datastorePreferences = "1.2.1" datastorePreferences = "1.2.1"
junit = "4.13.2" junit = "4.13.2"
kotlin = "2.3.21" kotlin = "2.4.0"
kotlinx-serialization = "1.11.0" kotlinx-serialization = "1.11.0"
material3 = "1.11.0-alpha07" material3 = "1.11.0-alpha07"
multiplatform-nav3-ui = "1.1.1" multiplatform-nav3-ui = "1.1.1"
ktor = "3.5.0" ktor = "3.5.1"
[libraries] [libraries]
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }

View File

@@ -3,6 +3,8 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins { plugins {
alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary) alias(libs.plugins.androidLibrary)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
kotlin("plugin.serialization") version libs.versions.kotlin.get() kotlin("plugin.serialization") version libs.versions.kotlin.get()
} }
@@ -31,10 +33,10 @@ kotlin {
implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.viewmodelCompose)
implementation(libs.androidx.lifecycle.runtimeCompose) implementation(libs.androidx.lifecycle.runtimeCompose)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0") implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
implementation("su.reya:nostr-sdk-kmp:0.3.2") implementation("su.reya:nostr-sdk-kmp:0.3.2")
implementation("com.squareup.okio:okio:3.16.2") implementation("com.squareup.okio:okio:3.17.0")
} }
androidMain.dependencies { androidMain.dependencies {
implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.okhttp)

View File

@@ -0,0 +1,20 @@
package su.reya.coop
import rust.nostr.sdk.Metadata
import rust.nostr.sdk.PublicKey
data class Profile(
val publicKey: PublicKey,
val metadata: Metadata
) {
private val record by lazy { metadata.asRecord() }
val name: String
get() = record.displayName ?: record.name ?: publicKey.short()
val picture: String?
get() = record.picture
val shortPublicKey: String
get() = publicKey.short()
}

View File

@@ -1,5 +1,12 @@
package su.reya.coop.nostr package su.reya.coop
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.minus import kotlinx.datetime.minus
@@ -8,6 +15,7 @@ import kotlinx.datetime.toLocalDateTime
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.Timestamp import rust.nostr.sdk.Timestamp
import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.viewmodel.NostrViewModel
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
@@ -75,9 +83,60 @@ data class Room(
return this.copy(lastMessage = message) return this.copy(lastMessage = message)
} }
fun isGroup(): Boolean { fun isGroup(): Boolean = members.size > 1
return members.size > 1
} }
data class RoomUiState(
val name: String = "Loading...",
val picture: String? = null,
val isGroup: Boolean = false
)
fun Room.uiStateFlow(
nostrViewModel: NostrViewModel,
currentUser: PublicKey? = null
): Flow<RoomUiState> {
val displayMembers = if (isGroup()) members.take(2) else members.take(1)
if (!subject.isNullOrBlank()) {
return flowOf(RoomUiState(name = subject, isGroup = isGroup()))
}
return combine(displayMembers.map { nostrViewModel.getMetadata(it) }) { profiles ->
val names = profiles.mapIndexed { i, profile -> profile?.name ?: displayMembers[i].short() }
val name = when {
isGroup() -> {
val combined = names.joinToString(", ")
val extra = members.size - names.size
if (extra > 0) "$combined, +$extra" else combined
}
else -> {
val first = names.firstOrNull() ?: "Unknown"
if (displayMembers.firstOrNull() == currentUser) "$first (you)" else first
}
}
RoomUiState(
name = name,
picture = profiles.firstOrNull()?.picture,
isGroup = isGroup()
)
}
}
@Composable
fun Room.rememberUiState(
viewModel: NostrViewModel,
currentUser: PublicKey? = null
): State<RoomUiState> {
return remember(this, currentUser) {
uiStateFlow(
viewModel,
currentUser
)
}.collectAsStateWithLifecycle(RoomUiState())
} }
fun UnsignedEvent.roomId(): Long { fun UnsignedEvent.roomId(): Long {

View File

@@ -24,6 +24,9 @@ import rust.nostr.sdk.Tag
import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnsignedEvent
import rust.nostr.sdk.nip17ExtractRelayList import rust.nostr.sdk.nip17ExtractRelayList
import rust.nostr.sdk.nip59MakeGiftWrapAsync import rust.nostr.sdk.nip59MakeGiftWrapAsync
import su.reya.coop.Room
import su.reya.coop.RoomKind
import su.reya.coop.roomId
import kotlin.time.Duration import kotlin.time.Duration
data class MessageSyncState( data class MessageSyncState(
@@ -47,7 +50,9 @@ class MessageManager(private val nostr: Nostr) {
suspend fun getUserMessages(msgRelayList: Event) { suspend fun getUserMessages(msgRelayList: Event) {
try { try {
val author = signer.currentUser ?: throw IllegalStateException("User not signed in") val author =
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
val relays = nip17ExtractRelayList(msgRelayList) val relays = nip17ExtractRelayList(msgRelayList)
// Ensure relay connections // Ensure relay connections

View File

@@ -79,6 +79,8 @@ class Nostr {
// Initialize configurations for nostr client // Initialize configurations for nostr client
val lmdb = NostrDatabase.lmdb(dbPath) val lmdb = NostrDatabase.lmdb(dbPath)
val gossip = NostrGossip.inMemory() val gossip = NostrGossip.inMemory()
// Initialize the authenticator
val authenticator = SignerAuthenticator(signer) val authenticator = SignerAuthenticator(signer)
val idleTimeout = Duration.parse("5m") val idleTimeout = Duration.parse("5m")
@@ -136,7 +138,7 @@ class Nostr {
fun isSignedByUser(event: Event): Boolean { fun isSignedByUser(event: Event): Boolean {
return try { return try {
signer.currentUser == event.author() signer.publicKeyFlow.value == event.author()
} catch (e: Exception) { } catch (e: Exception) {
println("Failed to check if event is signed by user: ${e.message}") println("Failed to check if event is signed by user: ${e.message}")
false false
@@ -208,7 +210,7 @@ class Nostr {
if (isSignedByUser(event = event)) { if (isSignedByUser(event = event)) {
val pubkeys = event.tags().publicKeys() val pubkeys = event.tags().publicKeys()
// Get mutual contacts // Get mutual contacts
profiles.getMutualContacts(pubkeys) profiles.syncMutualContacts(pubkeys)
// Emit contact list update // Emit contact list update
onContactListUpdate(pubkeys) onContactListUpdate(pubkeys)
} }

View File

@@ -49,7 +49,8 @@ class ProfileManager(private val nostr: Nostr) {
suspend fun getUserMetadata() { suspend fun getUserMetadata() {
try { try {
val author = signer.currentUser ?: throw IllegalStateException("User not signed in") val author =
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
// Get the latest metadata event // Get the latest metadata event
val metadataFilter = val metadataFilter =
@@ -73,22 +74,13 @@ class ProfileManager(private val nostr: Nostr) {
} }
} }
suspend fun getMutualContacts(pubkeys: List<PublicKey>) { suspend fun syncMutualContacts(pubkeys: List<PublicKey>) {
try { try {
val kind = Kind.fromStd(KindStandard.CONTACT_LIST) val kind = Kind.fromStd(KindStandard.CONTACT_LIST)
val filter = Filter().kind(kind).authors(pubkeys).limit(pubkeys.size.toULong()) val filter = Filter().kind(kind).authors(pubkeys).limit(pubkeys.size.toULong())
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) val relays = NostrManager.BOOTSTRAP_RELAYS.map { RelayUrl.parse(it) }
val target = mutableMapOf<RelayUrl, List<Filter>>() client?.sync(filter, relays)
NostrManager.BOOTSTRAP_RELAYS.forEach { relay ->
target[RelayUrl.parse(relay)] = listOf(filter)
}
client?.subscribe(
target = ReqTarget.manual(target),
id = "mutual-contacts",
closeOn = opts,
)
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to fetch mutual contacts: ${e.message}", e) throw IllegalStateException("Failed to fetch mutual contacts: ${e.message}", e)
} }
@@ -146,7 +138,8 @@ class ProfileManager(private val nostr: Nostr) {
bio: String? = null, bio: String? = null,
picture: String? = null picture: String? = null
): Metadata { ): Metadata {
val currentUser = signer.currentUser ?: throw IllegalStateException("User not signed in") val currentUser =
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
try { try {
val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord() val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord()

View File

@@ -134,8 +134,9 @@ class RelayManager(private val nostr: Nostr) {
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS) val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
val filter = Filter().kind(kind).author(publicKey).limit(1u) val filter = Filter().kind(kind).author(publicKey).limit(1u)
val events = client?.database()?.query(filter) val events = client?.database()?.query(filter)
val event = events?.first() ?: return emptyList()
return nip17ExtractRelayList(events?.toVec()?.firstOrNull() ?: return emptyList()) return nip17ExtractRelayList(event)
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to get msg relays: ${e.message}", e) throw IllegalStateException("Failed to get msg relays: ${e.message}", e)
} }

View File

@@ -1,5 +1,7 @@
package su.reya.coop.nostr package su.reya.coop.nostr
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
@@ -16,9 +18,8 @@ class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner {
@Volatile @Volatile
private var signer: AsyncNostrSigner = initialSigner private var signer: AsyncNostrSigner = initialSigner
@Volatile private val _publicKeyFlow = MutableStateFlow<PublicKey?>(null)
var currentUser: PublicKey? = null val publicKeyFlow = _publicKeyFlow.asStateFlow()
private set
/** /**
* Get the current signer. * Get the current signer.
@@ -37,7 +38,7 @@ class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner {
throw IllegalStateException("Failed to get public key from signer", e) throw IllegalStateException("Failed to get public key from signer", e)
} }
signer = newSigner signer = newSigner
currentUser = pubkey _publicKeyFlow.value = pubkey
} }
override suspend fun getPublicKeyAsync(): PublicKey? { override suspend fun getPublicKeyAsync(): PublicKey? {

View File

@@ -0,0 +1,13 @@
package su.reya.coop.repository
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
object ErrorRepository {
private val _errors = Channel<String>(Channel.BUFFERED)
val errors = _errors.receiveAsFlow()
fun showError(message: String) {
_errors.trySend(message)
}
}

View File

@@ -0,0 +1,39 @@
package su.reya.coop.repository
import io.ktor.client.HttpClient
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import rust.nostr.sdk.AsyncNostrSigner
import su.reya.coop.blossom.BlossomClient
class MediaRepository {
private val httpClient = HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
prettyPrint = true
isLenient = true
})
}
}
suspend fun blossomUpload(
signer: AsyncNostrSigner,
file: ByteArray,
contentType: String? = "image/jpeg"
): String? {
return try {
val blossom = BlossomClient(url = "https://blossom.band", client = httpClient)
val descriptor = blossom.upload(
file = file,
contentType = contentType,
signer = signer,
)
descriptor?.url
} catch (e: Exception) {
println("Upload failed: ${e.message}")
null
}
}
}

View File

@@ -1,9 +1,9 @@
package su.reya.coop.viewmodels package su.reya.coop.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import rust.nostr.sdk.AsyncNostrSigner import rust.nostr.sdk.AsyncNostrSigner
@@ -15,36 +15,56 @@ import su.reya.coop.nostr.ExternalSignerHandler
import su.reya.coop.nostr.ExternalSignerProxy import su.reya.coop.nostr.ExternalSignerProxy
import su.reya.coop.nostr.Nostr import su.reya.coop.nostr.Nostr
import su.reya.coop.nostr.SignerPermissions import su.reya.coop.nostr.SignerPermissions
import su.reya.coop.repository.MediaRepository
import su.reya.coop.storage.SecretStorage import su.reya.coop.storage.SecretStorage
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
class AccountViewModel( data class AuthState(
val nostr: Nostr, val isBusy: Boolean = false,
val signerRequired: Boolean? = null,
val isNotificationBannerDismissed: Boolean = false,
)
class AuthViewModel(
private val nostr: Nostr,
private val secretStore: SecretStorage, private val secretStore: SecretStorage,
private val appViewModel: AppViewModel,
private val externalSignerHandler: ExternalSignerHandler? = null, private val externalSignerHandler: ExternalSignerHandler? = null,
) : ViewModel() { ) : BaseViewModel() {
private val _signerRequired = MutableStateFlow<Boolean?>(null) private val mediaRepository = MediaRepository()
val signerRequired = _signerRequired.asStateFlow()
companion object {
private const val KEY_USER_SIGNER = "user_signer"
private const val KEY_APP_KEYS = "app_keys"
private const val KEY_BANNER_DISMISSED = "notification_banner_dismissed"
}
private val _state = MutableStateFlow(AuthState())
val state = _state.asStateFlow()
init { init {
// Skip the splash screen if a user is already logged in // Check if the notification banner has been dismissed
if (nostr.signer.currentUser != null) { checkNotificationBannerDismissedStatus()
_signerRequired.value = false
} // Check local stored secret (secret key or bunker)
// Check local stored secret
login() login()
} }
private fun checkNotificationBannerDismissedStatus() {
viewModelScope.launch {
val dismissed = secretStore.get(KEY_BANNER_DISMISSED) == "true"
_state.update { it.copy(isNotificationBannerDismissed = dismissed) }
}
}
private fun login() { private fun login() {
viewModelScope.launch { viewModelScope.launch {
try { try {
val secret = withTimeoutOrNull(3.seconds) { val secret = withTimeoutOrNull(3.seconds) {
secretStore.get("user_signer") secretStore.get(KEY_USER_SIGNER)
} }
if (secret == null) { if (secret == null) {
_signerRequired.value = true _state.update { it.copy(signerRequired = true) }
return@launch return@launch
} }
@@ -52,44 +72,59 @@ class AccountViewModel(
val signer = createSigner(secret) val signer = createSigner(secret)
nostr.setSigner(signer) nostr.setSigner(signer)
}.onSuccess { }.onSuccess {
_signerRequired.value = false _state.update { it.copy(signerRequired = false) }
}.onFailure { e -> }.onFailure { e ->
appViewModel.showError("Login failed: ${e.message}") showError("Login failed: ${e.message}")
_signerRequired.value = true _state.update { it.copy(signerRequired = true) }
} }
} catch (e: Exception) { } catch (e: Exception) {
appViewModel.showError("Login failed: ${e.message}") showError("Login failed: ${e.message}")
_signerRequired.value = true _state.update { it.copy(signerRequired = true) }
} }
} }
} }
fun logout() { fun logout(onLogout: () -> Unit = {}) {
viewModelScope.launch { viewModelScope.launch {
try { try {
appViewModel.setBusy(true) _state.update { it.copy(isBusy = true) }
// Reset the nostr signer and prune the database // Reset the nostr signer and prune the database
nostr.signer.switch(Keys.generate()) nostr.signer.switch(Keys.generate())
nostr.prune() nostr.prune()
} catch (e: Exception) { } catch (e: Exception) {
appViewModel.showError("Logout encountered an error: ${e.message}") showError("Logout encountered an error: ${e.message}")
} finally { } finally {
// Clear credentials from persistent storage // Clear credentials from persistent storage
secretStore.clear("user_signer") secretStore.clear(KEY_USER_SIGNER)
secretStore.clear("notification_banner_dismissed") secretStore.clear(KEY_BANNER_DISMISSED)
// Reset local states
appViewModel.setBusy(false) // Call cleanup callback (e.g. to reset other ViewModels)
_signerRequired.value = true onLogout()
_state.update { it.copy(isBusy = false, signerRequired = true) }
} }
} }
} }
fun dismissNotificationBanner() {
viewModelScope.launch {
secretStore.set(KEY_BANNER_DISMISSED, "true")
_state.update { it.copy(isNotificationBannerDismissed = true) }
}
}
private suspend fun getOrInitAppKeys(): Keys { private suspend fun getOrInitAppKeys(): Keys {
val secret = secretStore.get("app_keys") val secret = secretStore.get(KEY_APP_KEYS)
if (secret != null) return Keys.parse(secret)
// If app keys are already stored, use them
if (secret != null) {
return Keys.parse(secret)
}
// Generate new app keys and save to the secret storage
val keys = Keys.generate() val keys = Keys.generate()
secretStore.set("app_keys", keys.secretKey().toBech32()) secretStore.set(KEY_APP_KEYS, keys.secretKey().toBech32())
return keys return keys
} }
@@ -97,6 +132,7 @@ class AccountViewModel(
private suspend fun createSigner(secret: String): AsyncNostrSigner { private suspend fun createSigner(secret: String): AsyncNostrSigner {
return when { return when {
secret.startsWith("nsec1") -> Keys.parse(secret) secret.startsWith("nsec1") -> Keys.parse(secret)
secret.startsWith("bunker://") -> { secret.startsWith("bunker://") -> {
val appKeys = getOrInitAppKeys() val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret) val bunker = NostrConnectUri.parse(secret)
@@ -107,9 +143,12 @@ class AccountViewModel(
secret.startsWith("nip55://") -> { secret.startsWith("nip55://") -> {
val handler = externalSignerHandler val handler = externalSignerHandler
?: throw IllegalStateException("External signer not available on this platform") ?: throw IllegalStateException("External signer not available on this platform")
// Format: nip55://packageName/hexPubkey
val parts = secret.removePrefix("nip55://").split("/", limit = 2) val parts = secret.removePrefix("nip55://").split("/", limit = 2)
val packageName = parts[0] val packageName = parts[0]
val pubkey = PublicKey.parse(parts[1]) val pubkey = PublicKey.parse(parts[1])
handler.setPackageName(packageName) handler.setPackageName(packageName)
ExternalSignerProxy(handler, pubkey) ExternalSignerProxy(handler, pubkey)
} }
@@ -122,58 +161,34 @@ class AccountViewModel(
try { try {
val signer = createSigner(secret) val signer = createSigner(secret)
if (secret.startsWith("bunker://")) { if (secret.startsWith("bunker://")) {
appViewModel.showError("Please approve the connection.") showError("Please approve the connection.")
} }
return signer.getPublicKeyAsync() return signer.getPublicKeyAsync()
} catch (e: Exception) { } catch (e: Exception) {
appViewModel.showError("Error: ${e.message}") showError("Error: ${e.message}")
return null return null
} }
} }
suspend fun importIdentity(secret: String) { suspend fun importIdentity(secret: String) {
appViewModel.setBusy(true) _state.update { it.copy(isBusy = true) }
try { try {
val signer = createSigner(secret) val signer = createSigner(secret)
// Update signer
nostr.setSigner(signer) nostr.setSigner(signer)
secretStore.set("user_signer", secret) // Persist the secret in the secret storage
_signerRequired.value = false secretStore.set(KEY_USER_SIGNER, secret)
// Update local states
_state.update { it.copy(signerRequired = false, isBusy = false) }
} catch (e: Exception) { } catch (e: Exception) {
appViewModel.showError("Error: ${e.message}") showError("Error: ${e.message}")
} finally { _state.update { it.copy(isBusy = false) }
appViewModel.setBusy(false)
}
}
suspend fun createIdentity(
name: String,
bio: String?,
picture: ByteArray?,
contentType: String? = "image/jpeg",
) {
appViewModel.setBusy(true)
val keys = Keys.generate()
val secret = keys.secretKey().toBech32()
try {
val avatarUrl = picture?.let { appViewModel.blossomUpload(it, contentType) }
nostr.profiles.createIdentity(keys = keys, name = name, bio, picture = avatarUrl)
// Set credentials in persistent storage
secretStore.set("user_signer", secret)
// Update local state
_signerRequired.value = false
} catch (e: Exception) {
appViewModel.showError("Error: ${e.message}")
} finally {
appViewModel.setBusy(false)
} }
} }
suspend fun connectExternalSigner() { suspend fun connectExternalSigner() {
val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available") val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available")
appViewModel.setBusy(true) _state.update { it.copy(isBusy = true) }
try { try {
val permissions = SignerPermissions.toJson( val permissions = SignerPermissions.toJson(
listOf( listOf(
@@ -192,19 +207,53 @@ class AccountViewModel(
val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected") val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected")
val signer = ExternalSignerProxy(handler, result.pubkey) val signer = ExternalSignerProxy(handler, result.pubkey)
// Update signer
nostr.setSigner(signer) nostr.setSigner(signer)
// Set credentials in persistent storage // Store the signer in the secret storage
secretStore.set("user_signer", "nip55://${result.packageName}/${result.pubkey.toHex()}") secretStore.set(
// Update local state KEY_USER_SIGNER,
_signerRequired.value = false "nip55://${result.packageName}/${result.pubkey.toHex()}"
)
// Update local states
_state.update { it.copy(signerRequired = false, isBusy = false) }
} catch (e: Exception) { } catch (e: Exception) {
throw Exception("Notice: ${e.message}") _state.update { it.copy(isBusy = false) }
} finally { showError("Notice: ${e.message}")
appViewModel.setBusy(false)
} }
} }
fun isExternalSignerAvailable(): Boolean { fun isExternalSignerAvailable(): Boolean {
return externalSignerHandler?.isAvailable() == true return externalSignerHandler?.isAvailable() == true
} }
suspend fun createIdentity(
name: String,
bio: String?,
picture: ByteArray?,
contentType: String? = null
) {
_state.update { it.copy(isBusy = true) }
val keys = Keys.generate()
val secret = keys.secretKey().toBech32()
try {
val avatarUrl = picture?.let {
mediaRepository.blossomUpload(nostr.signer.get(), it, contentType ?: "image/jpeg")
}
// Create identity
nostr.profiles.createIdentity(keys = keys, name = name, bio = bio, picture = avatarUrl)
// Persist the secret in the secret storage
secretStore.set(KEY_USER_SIGNER, secret)
// Update local states
_state.update { it.copy(isBusy = false, signerRequired = false) }
} catch (e: Exception) {
showError("Error: ${e.message}")
_state.update { it.copy(isBusy = false) }
}
}
} }

View File

@@ -0,0 +1,10 @@
package su.reya.coop.viewmodel
import androidx.lifecycle.ViewModel
import su.reya.coop.repository.ErrorRepository
abstract class BaseViewModel : ViewModel() {
protected fun showError(message: String) {
ErrorRepository.showError(message)
}
}

View File

@@ -0,0 +1,260 @@
package su.reya.coop.viewmodel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import rust.nostr.sdk.EventBuilder
import rust.nostr.sdk.EventId
import rust.nostr.sdk.Kind
import rust.nostr.sdk.KindStandard
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.Tag
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.Room
import su.reya.coop.nostr.Nostr
import su.reya.coop.repository.MediaRepository
import su.reya.coop.roomId
data class ChatState(
val rooms: Set<Room> = emptySet(),
val isSyncing: Boolean = false,
val isPartialProcessedGiftWrap: Boolean = false,
)
class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
private val mediaRepository = MediaRepository()
private val _state = MutableStateFlow(ChatState())
val state = combine(
_state,
nostr.messages.messageSyncState
) { local, state -> local.copy(isSyncing = state.isSyncing) }.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
ChatState()
)
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
val newEvents = _newEvents.asSharedFlow()
private val _sentReports = MutableSharedFlow<Map<EventId, List<RelayUrl>>>()
val sentReport = _sentReports.asSharedFlow()
val chatRooms = state.map { it.rooms }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet())
val isSyncing = state.map { it.isSyncing }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
val isPartialProcessedGiftWrap = state.map { it.isPartialProcessedGiftWrap }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
init {
viewModelScope.launch {
nostr.waitUntilInitialized()
// Observe message sync progress
launch {
nostr.messages.messageSyncState.collect { syncState ->
// When at least some messages are processed, allow UI to show the list
if (syncState.processedCount > 0) {
_state.update { it.copy(isPartialProcessedGiftWrap = true) }
}
// Refresh UI every 10 messages OR when sync is fully done
if (syncState.processedCount % 10 == 0 || !syncState.isSyncing) {
refreshChatRooms()
}
}
}
// Observe new messages
launch {
nostr.newEvents.collect { event ->
val roomId = event.roomId()
val existingRoom = _state.value.rooms.firstOrNull { it.id == roomId }
if (existingRoom == null) {
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@collect
val newRoom = Room.new(event, currentUser)
_state.update {
it.copy(
rooms = (it.rooms + newRoom).sortedDescending().toSet()
)
}
} else {
updateRoomList(roomId, event)
}
_newEvents.emit(event)
}
}
// Initial load of rooms
refreshChatRooms()
}
}
fun createChatRoom(to: List<PublicKey>): Long {
try {
if (to.isEmpty()) {
throw IllegalArgumentException("At least one recipient is required")
}
// Get current user
val currentUser = nostr.signer.publicKeyFlow.value
?: throw IllegalStateException("User not signed in")
// Construct the rumor event
val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), "")
.tags(to.map { Tag.publicKey(it) })
.finalizeUnsigned(currentUser)
// Check if the room already exists
val id = rumor.roomId()
val existingRoom = _state.value.rooms.firstOrNull { it.id == id }
// If the room already exists, return its ID
if (existingRoom != null) {
return existingRoom.id
}
// Create a room from the rumor event
val room = Room.new(rumor, currentUser)
// Update the chat rooms state
_state.update { it.copy(rooms = (it.rooms + room).sortedDescending().toSet()) }
return room.id
} catch (e: Exception) {
throw IllegalArgumentException("Failed to create room: ${e.message}")
}
}
fun getChatRoom(id: Long): Room? {
return _state.value.rooms.firstOrNull { it.id == id }
}
suspend fun refreshChatRooms() {
try {
val rooms = nostr.messages.getChatRooms() ?: emptySet()
_state.update { currentState ->
val merged = currentState.rooms.associateBy { it.id }.toMutableMap()
// Add or update rooms from the database
rooms.forEach { room ->
merged[room.id] = room
}
// Return as a sorted set to maintain UI consistency
currentState.copy(rooms = merged.values.sortedDescending().toSet())
}
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> {
try {
return nostr.messages.getChatRoomMessages(roomId)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
return emptyList()
}
fun chatRoomConnect(roomId: Long) {
viewModelScope.launch {
try {
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
val members = room.members
nostr.messages.chatRoomConnect(members.toList())
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun sendMessage(roomId: Long, message: String, replies: List<EventId> = emptyList()) {
if (message.isEmpty()) {
showError("Message cannot be empty")
}
viewModelScope.launch {
try {
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
nostr.messages.sendMessage(
to = room.members,
content = message,
subject = room.subject,
replies = replies,
onRumorCreated = { event ->
updateRoomList(roomId, event)
viewModelScope.launch { _newEvents.emit(event) }
},
)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
suspend fun sendFileMessage(
roomId: Long,
file: ByteArray?,
contentType: String? = "image/jpeg",
replies: List<EventId> = emptyList()
) {
if (file == null) return
try {
val uri = mediaRepository.blossomUpload(nostr.signer.get(), file, contentType)
if (uri != null) sendMessage(roomId, uri, replies)
} catch (e: Exception) {
throw IllegalArgumentException("Error: ${e.message}")
}
}
fun isMessageSent(id: EventId): Boolean {
val giftWrapId = nostr.messages.rumorMap[id]
if (giftWrapId != null) {
val isSent = nostr.messages.sentEvents[giftWrapId]?.isNotEmpty() ?: false
return isSent
} else {
return false
}
}
private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) {
_state.update { currentState ->
val updatedRooms = currentState.rooms.map { room ->
if (room.id == roomId) {
room.copy(
lastMessage = newMessage.content(),
createdAt = newMessage.createdAt()
)
} else {
room
}
}.sortedDescending().toSet()
currentState.copy(rooms = updatedRooms)
}
}
fun resetInternalState() {
_state.update {
it.copy(
rooms = emptySet(),
isPartialProcessedGiftWrap = false,
)
}
}
}

View File

@@ -0,0 +1,446 @@
package su.reya.coop.viewmodel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.Timestamp
import su.reya.coop.Profile
import su.reya.coop.nostr.Nostr
import su.reya.coop.repository.MediaRepository
import kotlin.time.Clock
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
data class NostrAppState(
val isBusy: Boolean = false,
val isRelayListEmpty: Boolean = false,
)
class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
private val mediaRepository = MediaRepository()
private val alwaysRunTasks = flow {
coroutineScope {
val observerJob = launch { runObserver() }
val batchingJob = launch { runMetadataBatching() }
try {
emit(Unit)
awaitCancellation()
} finally {
observerJob.cancel()
batchingJob.cancel()
}
}
}
private val _appState = MutableStateFlow(NostrAppState())
val appState: StateFlow<NostrAppState> =
combine(_appState, alwaysRunTasks) { state, _ -> state }.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = NostrAppState()
)
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
val contactList = _contactList.asStateFlow()
private val profilesMutex = Mutex()
private val profiles = mutableMapOf<PublicKey, MutableStateFlow<Profile?>>()
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
private val seenPublicKeys = mutableSetOf<PublicKey>()
val isBusy = appState.map { it.isBusy }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
val isRelayListEmpty = appState.map { it.isRelayListEmpty }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
@OptIn(ExperimentalCoroutinesApi::class)
val currentUserProfile = nostr.signer.publicKeyFlow
.flatMapLatest { pubkey ->
if (pubkey != null) getMetadata(pubkey) else flowOf(null)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
init {
// Automatically reconnect bootstrap relays
reconnect()
// Observe the signer state and verify the relay list
observeSignerAndCheckRelays()
// Get all local stored metadata
getCacheMetadata()
}
private fun reconnect() {
viewModelScope.launch {
nostr.waitUntilInitialized()
nostr.reconnect()
}
}
private suspend fun runObserver() = coroutineScope {
// Observe contact list updates
launch {
nostr.profiles.contactListUpdates.collect { contacts ->
_contactList.value = contacts.toSet()
}
}
// Observe metadata updates
launch {
nostr.profiles.metadataUpdates.collect { (pubkey, metadata) ->
updateMetadata(pubkey, Profile(pubkey, metadata))
}
}
}
private suspend fun runMetadataBatching() = coroutineScope {
// Wait until the client is ready
nostr.waitUntilInitialized()
val batch = mutableSetOf<PublicKey>()
val timeout = 500L // 500ms timeout for batching
while (true) {
// Get the first pubkey
val firstKey = metadataRequestChannel.receive()
batch.add(firstKey)
// Get current time
val lastFlushTime = Clock.System.now().toEpochMilliseconds()
while (batch.isNotEmpty()) {
// Get the next pubkey
val nextKey = withTimeoutOrNull(timeout.milliseconds) {
metadataRequestChannel.receive()
}
// Only add the pubkey if it's not null
if (nextKey != null) batch.add(nextKey)
// Get current time
val now = Clock.System.now().toEpochMilliseconds()
// Check if the batch is full or timeout has passed
if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) {
val keysToRequest = batch.toList()
batch.clear()
nostr.profiles.fetchMetadataBatch(keysToRequest)
}
}
}
}
private fun getCacheMetadata() {
viewModelScope.launch {
// Wait until the client is ready
nostr.waitUntilInitialized()
nostr.profiles.getAllCacheMetadata().forEach { (pubkey, metadata) ->
// Update the metadata state
updateMetadata(pubkey, Profile(pubkey, metadata))
// Update seenPublicKeys to avoid duplicate requests
seenPublicKeys.add(pubkey)
}
}
}
private fun observeSignerAndCheckRelays() {
viewModelScope.launch {
// Wait until the client is ready
nostr.waitUntilInitialized()
// Wait until a signer is explicitly set (which updates publicKeyFlow)
val currentUser = nostr.signer.publicKeyFlow.filterNotNull().first()
// Get all metadata for the current user
nostr.profiles.getUserMetadata()
// Small delay to ensure all relays are connected
delay(2.seconds)
// Check if the relay list is empty
val relays = nostr.relays.getMsgRelays(currentUser)
if (relays.isEmpty()) _appState.update { it.copy(isRelayListEmpty = true) }
}
}
private fun requestMetadata(pubkey: PublicKey) {
if (seenPublicKeys.add(pubkey)) {
viewModelScope.launch {
metadataRequestChannel.send(pubkey)
}
}
}
private fun updateMetadata(pubkey: PublicKey, profile: Profile) {
viewModelScope.launch {
profilesMutex.withLock {
profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile
}
}
}
fun getMetadata(pubkey: PublicKey): StateFlow<Profile?> {
val flow = profiles.getOrPut(pubkey) { MutableStateFlow(null) }
if (flow.value == null) requestMetadata(pubkey)
return flow.asStateFlow()
}
fun resetInternalState() {
_contactList.value = emptySet()
_appState.update {
it.copy(
isRelayListEmpty = false,
)
}
}
fun dismissRelayWarning() {
_appState.update { it.copy(isRelayListEmpty = false) }
}
suspend fun updateProfile(
name: String? = null,
bio: String? = null,
picture: ByteArray? = null,
contentType: String? = null
) {
_appState.update { it.copy(isBusy = true) }
try {
val avatarUrl =
picture?.let {
mediaRepository.blossomUpload(
nostr.signer.get(),
it,
contentType ?: "image/jpeg"
)
}
val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl)
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
// Update the metadata state after successfully published
updateMetadata(currentUser, Profile(currentUser, newMetadata))
// Update local state
_appState.update { it.copy(isBusy = false) }
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun refetchMsgRelays() {
val currentUser = nostr.signer.getPublicKeyAsync() ?: return
val relays = nostr.relays.fetchMsgRelays(currentUser)
if (relays.isNotEmpty()) dismissRelayWarning()
}
suspend fun useDefaultMsgRelayList() {
try {
val defaultRelays = nostr.relays.getDefaultMsgRelayList()
nostr.relays.setMsgRelays(defaultRelays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun currentUserRelayList(): Map<RelayUrl, RelayMetadata?> {
try {
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
return nostr.relays.getRelayList(currentUser)
} catch (e: Exception) {
showError("Error: ${e.message}")
return emptyMap()
}
}
suspend fun addInboxRelay(relay: String) {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayList().toMutableMap()
relays[relayUrl] = RelayMetadata.WRITE
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun addOutboxRelay(relay: String) {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayList().toMutableMap()
relays[relayUrl] = RelayMetadata.READ
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun removeRelay(relay: String) {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayList().toMutableMap()
relays.remove(relayUrl)
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun currentUserMsgRelayList(): List<RelayUrl> {
try {
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
return nostr.relays.getMsgRelays(currentUser)
} catch (e: Exception) {
showError("Error: ${e.message}")
return emptyList()
}
}
suspend fun addMsgRelay(relay: String) {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserMsgRelayList().toMutableSet()
relays.add(relayUrl)
nostr.relays.setMsgRelays(relays.toList())
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun removeMsgRelay(relay: String) {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserMsgRelayList().toMutableSet()
relays.remove(relayUrl)
nostr.relays.setMsgRelays(relays.toList())
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
private suspend fun newContact(publicKey: PublicKey) {
if (publicKey in contactList.value) return
try {
val updated = contactList.value + publicKey
// Publish new event
nostr.profiles.setContactList(updated.toList())
// Optimistic local update
_contactList.update { it + publicKey }
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun addContact(address: String): Boolean {
val pubkey = try {
if (address.contains("@")) {
nostr.profiles.searchByAddress(address)
} else {
PublicKey.parse(address)
}
} catch (e: Exception) {
showError("Invalid contact address: ${e.message}")
return false
}
return run {
newContact(pubkey)
true
}
}
fun removeContact(publicKey: PublicKey) {
viewModelScope.launch {
if (publicKey !in contactList.value) return@launch
try {
val updated = contactList.value - publicKey
// Publish new event
nostr.profiles.setContactList(updated.toList())
// Optimistic local update
_contactList.update { it - publicKey }
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
suspend fun searchByAddress(query: String): PublicKey? {
try {
return nostr.profiles.searchByAddress(query)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
return null
}
suspend fun searchByNostr(query: String): List<PublicKey> {
try {
return nostr.profiles.searchByNostr(query)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
return emptyList()
}
suspend fun verifyActivity(pubkey: PublicKey): Timestamp? {
return try {
nostr.profiles.verifyActivity(pubkey)
} catch (e: Exception) {
showError("Error: ${e.message}")
null
}
}
suspend fun verifyContact(pubkey: PublicKey): Boolean {
return try {
nostr.profiles.verifyContact(pubkey)
} catch (e: Exception) {
showError("Error: ${e.message}")
false
}
}
suspend fun mutualContacts(pubkey: PublicKey): Set<PublicKey> {
return try {
nostr.profiles.mutualContacts(pubkey)
} catch (e: Exception) {
showError("Error: ${e.message}")
setOf()
}
}
}

View File

@@ -1,92 +0,0 @@
package su.reya.coop.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.ktor.client.HttpClient
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import su.reya.coop.blossom.BlossomClient
import su.reya.coop.nostr.Nostr
import su.reya.coop.storage.SecretStorage
class AppViewModel(
private val nostr: Nostr,
private val secretStore: SecretStorage
) : ViewModel() {
private val _isBusy = MutableStateFlow(false)
val isBusy = _isBusy.asStateFlow()
private val _isNotificationBannerDismissed = MutableStateFlow(false)
val isNotificationBannerDismissed = _isNotificationBannerDismissed.asStateFlow()
private val _errorEvents = Channel<String>(Channel.BUFFERED)
val errorEvents = _errorEvents.receiveAsFlow()
init {
checkNotificationBannerDismissedStatus()
}
fun setBusy(busy: Boolean) {
_isBusy.value = busy
}
fun showError(message: String) {
viewModelScope.launch {
_errorEvents.send(message)
}
}
private fun checkNotificationBannerDismissedStatus() {
viewModelScope.launch {
_isNotificationBannerDismissed.value =
secretStore.get("notification_banner_dismissed") == "true"
}
}
fun dismissNotificationBanner() {
viewModelScope.launch {
secretStore.set("notification_banner_dismissed", "true")
_isNotificationBannerDismissed.value = true
}
}
suspend fun blossomUpload(file: ByteArray, contentType: String? = "image/jpeg"): String? {
val blossom = BlossomClient(
url = "https://blossom.band",
client = HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
prettyPrint = true
isLenient = true
})
}
}
)
val descriptor = blossom.upload(
file = file,
contentType = contentType,
signer = nostr.signer.get()
)
return descriptor?.url
}
override fun onCleared() {
super.onCleared()
viewModelScope.launch {
withContext(NonCancellable) {
nostr.disconnect()
}
}
}
}

View File

@@ -1,221 +0,0 @@
package su.reya.coop.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import rust.nostr.sdk.EventBuilder
import rust.nostr.sdk.EventId
import rust.nostr.sdk.Kind
import rust.nostr.sdk.KindStandard
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.Tag
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.nostr.Nostr
import su.reya.coop.nostr.Room
import su.reya.coop.nostr.roomId
class ChatViewModel(
private val nostr: Nostr,
private val appViewModel: AppViewModel
) : ViewModel() {
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
val chatRooms = _chatRooms.asStateFlow()
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
val newEvents = _newEvents.asSharedFlow()
private val _isPartialProcessedGiftWrap = MutableStateFlow(false)
val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow()
val isSyncing = nostr.messages.messageSyncState.map { it.isSyncing }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
suspend fun bindObservers() = coroutineScope {
launch {
nostr.messages.messageSyncState.collect { state ->
if (state.processedCount > 0) {
_isPartialProcessedGiftWrap.value = true
}
if (state.processedCount % 10 == 0 || !state.isSyncing) {
refreshChatRooms()
}
}
}
launch {
nostr.newEvents.collect { event ->
val roomId = event.roomId()
val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId }
if (existingRoom == null) {
val currentUser = nostr.signer.currentUser
if (currentUser != null) {
val newRoom = Room.new(event, currentUser)
_chatRooms.update { (it + newRoom).sortedDescending().toSet() }
}
} else {
updateRoomList(roomId, event)
}
_newEvents.emit(event)
}
}
}
fun createChatRoom(to: List<PublicKey>): Long {
try {
val currentUser =
nostr.signer.currentUser ?: throw IllegalStateException("User not signed in")
if (to.isEmpty()) throw IllegalArgumentException("At least one recipient is required")
val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), "")
.tags(to.map { Tag.publicKey(it) })
.finalizeUnsigned(currentUser)
val id = rumor.roomId()
val existingRoom = _chatRooms.value.firstOrNull { it.id == id }
if (existingRoom != null) {
return existingRoom.id
}
val room = Room.new(rumor, currentUser)
_chatRooms.update { currentRooms ->
(currentRooms + room).sortedDescending().toSet()
}
return room.id
} catch (e: Exception) {
throw IllegalArgumentException("Failed to create room: ${e.message}")
}
}
fun getChatRoom(id: Long): Room? {
return chatRooms.value.firstOrNull { it.id == id }
}
fun getChatRooms() {
viewModelScope.launch {
val rooms = nostr.messages.getChatRooms() ?: emptySet()
mergeChatRooms(rooms)
}
}
suspend fun refreshChatRooms() {
try {
val rooms = nostr.messages.getChatRooms() ?: emptySet()
mergeChatRooms(rooms)
} catch (e: Exception) {
appViewModel.showError("Error: ${e.message}")
}
}
fun mergeChatRooms(rooms: Set<Room>) {
_chatRooms.update { currentRooms ->
val merged = currentRooms.associateBy { it.id }.toMutableMap()
rooms.forEach { room ->
merged[room.id] = room
}
merged.values.sortedDescending().toSet()
}
}
suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> {
try {
return nostr.messages.getChatRoomMessages(roomId)
} catch (e: Exception) {
appViewModel.showError("Error: ${e.message}")
}
return emptyList()
}
fun chatRoomConnect(roomId: Long) {
viewModelScope.launch {
try {
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
nostr.messages.chatRoomConnect(room.members.toList())
} catch (e: Exception) {
appViewModel.showError("Error: ${e.message}")
}
}
}
fun sendMessage(
roomId: Long,
message: String,
replies: List<EventId> = emptyList()
) {
if (message.isEmpty()) {
appViewModel.showError("Message cannot be empty")
return
}
viewModelScope.launch {
try {
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
nostr.messages.sendMessage(
to = room.members,
content = message,
subject = room.subject,
replies = replies,
onRumorCreated = { event ->
updateRoomList(roomId, event)
viewModelScope.launch { _newEvents.emit(event) }
},
)
} catch (e: Exception) {
appViewModel.showError("Error: ${e.message}")
}
}
}
suspend fun sendFileMessage(
roomId: Long,
file: ByteArray?,
contentType: String? = "image/jpeg",
replies: List<EventId> = emptyList()
) {
if (file == null) return
try {
val uri = appViewModel.blossomUpload(file, contentType)
?: throw IllegalArgumentException("Failed to upload file")
sendMessage(roomId, uri, replies)
} catch (e: Exception) {
throw IllegalArgumentException("Error: ${e.message}")
}
}
fun isMessageSent(id: EventId): Boolean {
val giftWrapId = nostr.messages.rumorMap[id]
return if (giftWrapId != null) {
nostr.messages.sentEvents[giftWrapId]?.isNotEmpty() ?: false
} else {
false
}
}
private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) {
_chatRooms.update { currentRooms ->
currentRooms.map { room ->
if (room.id == roomId) {
room.copy(
lastMessage = newMessage.content(),
createdAt = newMessage.createdAt()
)
} else {
room
}
}.sortedDescending().toSet()
}
}
}

View File

@@ -1,216 +0,0 @@
package su.reya.coop.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import rust.nostr.sdk.Metadata
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.Timestamp
import su.reya.coop.nostr.Nostr
import kotlin.time.Clock
import kotlin.time.Duration.Companion.milliseconds
class ProfileViewModel(
private val nostr: Nostr,
private val appViewModel: AppViewModel
) : ViewModel() {
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
val contactList = _contactList.asStateFlow()
private val _metadataStore = mutableMapOf<PublicKey, MutableStateFlow<Metadata?>>()
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
private val seenPublicKeys = mutableSetOf<PublicKey>()
init {
getCacheMetadata()
}
suspend fun bindObservers() = coroutineScope {
launch {
nostr.profiles.contactListUpdates.collect { contacts ->
_contactList.value = contacts.toSet()
}
}
launch {
nostr.profiles.metadataUpdates.collect { (pubkey, metadata) ->
updateMetadata(pubkey, metadata)
}
}
launch { runMetadataBatching() }
}
private fun updateMetadata(pubkey: PublicKey, metadata: Metadata) {
_metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }.value = metadata
}
fun getMetadata(pubkey: PublicKey): StateFlow<Metadata?> {
val flow = _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }
if (flow.value == null) {
requestMetadata(pubkey)
}
return flow.asStateFlow()
}
private fun requestMetadata(pubkey: PublicKey) {
if (seenPublicKeys.add(pubkey)) {
viewModelScope.launch {
metadataRequestChannel.send(pubkey)
}
}
}
private fun getCacheMetadata() {
viewModelScope.launch {
nostr.waitUntilInitialized()
val results = nostr.profiles.getAllCacheMetadata()
results.forEach { (pubkey, metadata) ->
updateMetadata(pubkey, metadata)
seenPublicKeys.add(pubkey)
}
}
}
private suspend fun runMetadataBatching() = coroutineScope {
nostr.waitUntilInitialized()
val batch = mutableSetOf<PublicKey>()
val timeout = 500L
while (true) {
val firstKey = metadataRequestChannel.receive()
batch.add(firstKey)
val lastFlushTime = Clock.System.now().toEpochMilliseconds()
while (batch.isNotEmpty()) {
val nextKey = withTimeoutOrNull(timeout.milliseconds) {
metadataRequestChannel.receive()
}
if (nextKey != null) batch.add(nextKey)
val now = Clock.System.now().toEpochMilliseconds()
if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) {
val keysToRequest = batch.toList()
batch.clear()
nostr.profiles.fetchMetadataBatch(keysToRequest)
}
}
}
}
suspend fun updateProfile(
name: String? = null,
bio: String? = null,
picture: ByteArray? = null,
contentType: String? = "image/jpeg"
) {
appViewModel.setBusy(true)
try {
val avatarUrl = picture?.let { appViewModel.blossomUpload(it, contentType) }
val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl)
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
updateMetadata(currentUser, newMetadata)
} catch (e: Exception) {
appViewModel.showError("Error: ${e.message}")
} finally {
appViewModel.setBusy(false)
}
}
suspend fun addContact(address: String): Boolean {
val pubkey = try {
if (address.contains("@")) {
nostr.profiles.searchByAddress(address)
} else {
PublicKey.parse(address)
}
} catch (e: Exception) {
appViewModel.showError("Invalid contact address: ${e.message}")
return false
}
if (pubkey in contactList.value) return true
return try {
val updated = contactList.value + pubkey
nostr.profiles.setContactList(updated.toList())
_contactList.update { it + pubkey }
true
} catch (e: Exception) {
appViewModel.showError("Error: ${e.message}")
false
}
}
fun removeContact(publicKey: PublicKey) {
viewModelScope.launch {
if (publicKey !in contactList.value) return@launch
try {
val updated = contactList.value - publicKey
nostr.profiles.setContactList(updated.toList())
_contactList.update { it - publicKey }
} catch (e: Exception) {
appViewModel.showError("Error: ${e.message}")
}
}
}
suspend fun searchByAddress(query: String): PublicKey? {
try {
return nostr.profiles.searchByAddress(query)
} catch (e: Exception) {
appViewModel.showError("Error: ${e.message}")
}
return null
}
suspend fun searchByNostr(query: String): List<PublicKey> {
try {
return nostr.profiles.searchByNostr(query)
} catch (e: Exception) {
appViewModel.showError("Error: ${e.message}")
}
return emptyList()
}
suspend fun verifyActivity(pubkey: PublicKey): Timestamp? {
return try {
nostr.profiles.verifyActivity(pubkey)
} catch (e: Exception) {
appViewModel.showError("Error: ${e.message}")
null
}
}
suspend fun verifyContact(pubkey: PublicKey): Boolean {
return try {
nostr.profiles.verifyContact(pubkey)
} catch (e: Exception) {
appViewModel.showError("Error: ${e.message}")
false
}
}
suspend fun mutualContacts(pubkey: PublicKey): Set<PublicKey> {
return try {
nostr.profiles.mutualContacts(pubkey)
} catch (e: Exception) {
appViewModel.showError("Error: ${e.message}")
setOf()
}
}
fun getUserMetadata() {
viewModelScope.launch {
nostr.profiles.getUserMetadata()
}
}
}

View File

@@ -1,149 +0,0 @@
package su.reya.coop.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl
import su.reya.coop.nostr.Nostr
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
class RelayViewModel(
private val nostr: Nostr,
private val appViewModel: AppViewModel,
private val chatViewModel: ChatViewModel,
private val profileViewModel: ProfileViewModel
) : ViewModel() {
private val _isRelayListEmpty = MutableStateFlow(false)
val isRelayListEmpty = _isRelayListEmpty.asStateFlow()
fun reconnect() {
viewModelScope.launch {
nostr.waitUntilInitialized()
nostr.reconnect()
}
}
fun observeSignerAndCheckRelays() {
viewModelScope.launch {
while (true) {
val pubkey = nostr.signer.currentUser
if (pubkey != null) {
val rooms = nostr.messages.getChatRooms() ?: emptySet()
if (rooms.isNotEmpty()) {
chatViewModel.mergeChatRooms(rooms)
// Note: isPartialProcessedGiftWrap is in ChatViewModel
// We might need to expose a way to set it or just let it be updated by observers
}
profileViewModel.getUserMetadata()
delay(2.seconds)
val relays = nostr.relays.getMsgRelays(pubkey)
if (relays.isEmpty()) _isRelayListEmpty.value = true
break
}
delay(500.milliseconds)
}
}
}
suspend fun refetchMsgRelays(pubkey: PublicKey) {
val relays = nostr.relays.fetchMsgRelays(pubkey)
if (relays.isNotEmpty()) dismissRelayWarning()
}
fun dismissRelayWarning() {
_isRelayListEmpty.value = false
}
suspend fun useDefaultMsgRelayList() {
try {
val defaultRelays = nostr.relays.getDefaultMsgRelayList()
nostr.relays.setMsgRelays(defaultRelays)
} catch (e: Exception) {
appViewModel.showError("Error: ${e.message}")
}
}
suspend fun currentUserRelayList(): Map<RelayUrl, RelayMetadata?> {
try {
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
return nostr.relays.getRelayList(currentUser)
} catch (e: Exception) {
appViewModel.showError("Error: ${e.message}")
return emptyMap()
}
}
suspend fun addInboxRelay(relay: String) {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayList().toMutableMap()
relays[relayUrl] = RelayMetadata.WRITE
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
appViewModel.showError("Error: ${e.message}")
}
}
suspend fun addOutboxRelay(relay: String) {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayList().toMutableMap()
relays[relayUrl] = RelayMetadata.READ
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
appViewModel.showError("Error: ${e.message}")
}
}
suspend fun removeRelay(relay: String) {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayList().toMutableMap()
relays.remove(relayUrl)
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
appViewModel.showError("Error: ${e.message}")
}
}
suspend fun currentUserMsgRelayList(): List<RelayUrl> {
try {
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
return nostr.relays.getMsgRelays(currentUser)
} catch (e: Exception) {
appViewModel.showError("Error: ${e.message}")
return emptyList()
}
}
suspend fun addMsgRelay(relay: String) {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserMsgRelayList().toMutableSet()
relays.add(relayUrl)
nostr.relays.setMsgRelays(relays.toList())
} catch (e: Exception) {
appViewModel.showError("Error: ${e.message}")
}
}
suspend fun removeMsgRelay(relay: String) {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserMsgRelayList().toMutableSet()
relays.remove(relayUrl)
nostr.relays.setMsgRelays(relays.toList())
} catch (e: Exception) {
appViewModel.showError("Error: ${e.message}")
}
}
}