chore: refactor the internal structure #33
@@ -52,6 +52,10 @@ val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
|
||||
error("No NostrViewModel provided")
|
||||
}
|
||||
|
||||
val LocalChatViewModel = staticCompositionLocalOf<ChatViewModel> {
|
||||
error("No ChatViewModel provided")
|
||||
}
|
||||
|
||||
val LocalAuthViewModel = staticCompositionLocalOf<AuthViewModel> {
|
||||
error("No AuthViewModel provided")
|
||||
}
|
||||
@@ -72,6 +76,7 @@ val LocalScanResult = staticCompositionLocalOf<QrScanResult> {
|
||||
@Composable
|
||||
fun App(
|
||||
nostrViewModel: NostrViewModel,
|
||||
chatViewModel: ChatViewModel,
|
||||
authViewModel: AuthViewModel,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -153,6 +158,7 @@ fun App(
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalNostrViewModel provides nostrViewModel,
|
||||
LocalChatViewModel provides chatViewModel,
|
||||
LocalAuthViewModel provides authViewModel,
|
||||
LocalSnackbarHostState provides snackbarHostState,
|
||||
LocalNavigator provides navigator,
|
||||
|
||||
@@ -28,12 +28,15 @@ class MainActivity : ComponentActivity() {
|
||||
private val secretStore = SecretStore(this@MainActivity)
|
||||
private val nostrViewModel =
|
||||
NostrViewModel(NostrManager.instance)
|
||||
private val chatViewModel =
|
||||
ChatViewModel(NostrManager.instance)
|
||||
private val authViewModel =
|
||||
AuthViewModel(NostrManager.instance, secretStore, androidSigner)
|
||||
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return when {
|
||||
modelClass.isAssignableFrom(NostrViewModel::class.java) -> nostrViewModel
|
||||
modelClass.isAssignableFrom(ChatViewModel::class.java) -> chatViewModel
|
||||
modelClass.isAssignableFrom(AuthViewModel::class.java) -> authViewModel
|
||||
else -> throw IllegalArgumentException("Unknown ViewModel class")
|
||||
} as T
|
||||
@@ -42,6 +45,7 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
private val nostrViewModel: NostrViewModel by viewModels { factory }
|
||||
private val chatViewModel: ChatViewModel by viewModels { factory }
|
||||
private val authViewModel: AuthViewModel by viewModels { factory }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -87,6 +91,7 @@ class MainActivity : ComponentActivity() {
|
||||
setContent {
|
||||
App(
|
||||
nostrViewModel = nostrViewModel,
|
||||
chatViewModel = chatViewModel,
|
||||
authViewModel = authViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.Timestamp
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import su.reya.coop.LocalChatViewModel
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
@@ -96,6 +97,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val navigator = LocalNavigator.current
|
||||
val nostrViewModel = LocalNostrViewModel.current
|
||||
val chatViewModel = LocalChatViewModel.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
@@ -103,7 +105,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
||||
val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
|
||||
|
||||
// Get chat room by ID
|
||||
val chatRooms by nostrViewModel.chatRooms.collectAsStateWithLifecycle()
|
||||
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
|
||||
val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } }
|
||||
|
||||
// Show empty screen
|
||||
@@ -144,7 +146,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
||||
val type = context.contentResolver.getType(uri)
|
||||
|
||||
// Send message
|
||||
nostrViewModel.sendFileMessage(id, file, type)
|
||||
chatViewModel.sendFileMessage(id, file, type)
|
||||
} catch (e: Exception) {
|
||||
snackbarHostState.showSnackbar("Error: ${e.message}")
|
||||
}
|
||||
@@ -157,7 +159,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
||||
|
||||
LaunchedEffect(id) {
|
||||
// Get messages
|
||||
val initialMessages = nostrViewModel.getChatRoomMessages(id)
|
||||
val initialMessages = chatViewModel.getChatRoomMessages(id)
|
||||
messages.clear()
|
||||
messages.addAll(initialMessages)
|
||||
|
||||
@@ -165,10 +167,10 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
||||
loading = false
|
||||
|
||||
// Get msg relays for each member
|
||||
nostrViewModel.chatRoomConnect(id)
|
||||
chatViewModel.chatRoomConnect(id)
|
||||
|
||||
// Handle new messages
|
||||
nostrViewModel.newEvents.collect { event ->
|
||||
chatViewModel.newEvents.collect { event ->
|
||||
if (event.roomId() == id) {
|
||||
if (event.id() !in messages.map { it.id() }) {
|
||||
messages.add(0, event)
|
||||
@@ -350,7 +352,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
onSend = {
|
||||
nostrViewModel.sendMessage(id, text)
|
||||
chatViewModel.sendMessage(id, text)
|
||||
text = ""
|
||||
},
|
||||
onUpload = {
|
||||
|
||||
@@ -91,6 +91,7 @@ import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import su.reya.coop.LocalAuthViewModel
|
||||
import su.reya.coop.LocalChatViewModel
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalScanResult
|
||||
@@ -112,6 +113,7 @@ fun HomeScreen() {
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val clipboardManager = LocalClipboard.current
|
||||
val nostrViewModel = LocalNostrViewModel.current
|
||||
val chatViewModel = LocalChatViewModel.current
|
||||
val authViewModel = LocalAuthViewModel.current
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -120,11 +122,11 @@ fun HomeScreen() {
|
||||
val pullToRefreshState = rememberPullToRefreshState()
|
||||
|
||||
val userProfile by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
|
||||
val chatRooms by nostrViewModel.chatRooms.collectAsStateWithLifecycle()
|
||||
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
|
||||
|
||||
val isRelayListEmpty by nostrViewModel.isRelayListEmpty.collectAsStateWithLifecycle()
|
||||
val isSyncing by nostrViewModel.isSyncing.collectAsStateWithLifecycle()
|
||||
val isPartialProcessedGiftWrap by nostrViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
|
||||
val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle()
|
||||
val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
|
||||
|
||||
val authState by authViewModel.state.collectAsStateWithLifecycle()
|
||||
val isBannerDismissed = authState.isNotificationBannerDismissed
|
||||
@@ -155,7 +157,7 @@ fun HomeScreen() {
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
nostrViewModel.getChatRooms()
|
||||
chatViewModel.refreshChatRooms()
|
||||
}
|
||||
|
||||
LaunchedEffect(qrScanResult.content) {
|
||||
@@ -163,7 +165,7 @@ fun HomeScreen() {
|
||||
runCatching { PublicKey.parse(result) }
|
||||
.onSuccess { pubkey ->
|
||||
try {
|
||||
val roomId = nostrViewModel.createChatRoom(listOf(pubkey))
|
||||
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
|
||||
navigator.navigate(Screen.Chat(roomId))
|
||||
} catch (e: Exception) {
|
||||
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||
@@ -320,7 +322,7 @@ fun HomeScreen() {
|
||||
onRefresh = {
|
||||
scope.launch {
|
||||
isRefreshing = true
|
||||
nostrViewModel.refreshChatRooms()
|
||||
chatViewModel.refreshChatRooms()
|
||||
isRefreshing = false
|
||||
}
|
||||
},
|
||||
@@ -746,6 +748,7 @@ fun BottomMenuList(
|
||||
) {
|
||||
val navigator = LocalNavigator.current
|
||||
val nostrViewModel = LocalNostrViewModel.current
|
||||
val chatViewModel = LocalChatViewModel.current
|
||||
val authViewModel = LocalAuthViewModel.current
|
||||
|
||||
val defaultMenuList = listOf(
|
||||
@@ -776,7 +779,14 @@ fun BottomMenuList(
|
||||
}
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
FilledTonalButton(
|
||||
onClick = { onDismiss { authViewModel.logout(onLogout = nostrViewModel::resetInternalState) } },
|
||||
onClick = {
|
||||
onDismiss {
|
||||
authViewModel.logout(onLogout = {
|
||||
nostrViewModel.resetInternalState()
|
||||
chatViewModel.resetInternalState()
|
||||
})
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError
|
||||
|
||||
@@ -55,6 +55,7 @@ import coop.composeapp.generated.resources.ic_scanner
|
||||
import kotlinx.coroutines.delay
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import su.reya.coop.LocalChatViewModel
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalScanResult
|
||||
@@ -71,6 +72,7 @@ fun NewChatScreen() {
|
||||
val navigator = LocalNavigator.current
|
||||
val qrScanResult = LocalScanResult.current
|
||||
val nostrViewModel = LocalNostrViewModel.current
|
||||
val chatViewModel = LocalChatViewModel.current
|
||||
|
||||
val contactList by nostrViewModel.contactList.collectAsStateWithLifecycle()
|
||||
var query by remember { mutableStateOf("") }
|
||||
@@ -169,7 +171,7 @@ fun NewChatScreen() {
|
||||
) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = {
|
||||
val roomId = nostrViewModel.createChatRoom(selectedReceivers.toList())
|
||||
val roomId = chatViewModel.createChatRoom(selectedReceivers.toList())
|
||||
navigator.navigate(Screen.Chat(roomId))
|
||||
},
|
||||
expanded = false,
|
||||
@@ -260,7 +262,7 @@ fun NewChatScreen() {
|
||||
items = searchResults,
|
||||
selectedReceivers = selectedReceivers,
|
||||
onContactClick = { pubkey ->
|
||||
val roomId = nostrViewModel.createChatRoom(listOf(pubkey))
|
||||
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
|
||||
navigator.navigate(Screen.Chat(roomId))
|
||||
},
|
||||
)
|
||||
@@ -271,7 +273,7 @@ fun NewChatScreen() {
|
||||
items = contactList.toList(),
|
||||
selectedReceivers = selectedReceivers,
|
||||
onContactClick = { pubkey ->
|
||||
val roomId = nostrViewModel.createChatRoom(listOf(pubkey))
|
||||
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
|
||||
navigator.navigate(Screen.Chat(roomId))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -44,6 +44,7 @@ import coop.composeapp.generated.resources.ic_share
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import su.reya.coop.LocalChatViewModel
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
@@ -55,19 +56,20 @@ import su.reya.coop.short
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ProfileScreen(pubkey: String) {
|
||||
val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
|
||||
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val navigator = LocalNavigator.current
|
||||
val nostrViewModel = LocalNostrViewModel.current
|
||||
val chatViewModel = LocalChatViewModel.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val pubkeyObj = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
|
||||
|
||||
val profileFlow = remember(pubkeyObj) { nostrViewModel.getMetadata(pubkeyObj) }
|
||||
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
|
||||
val profile by profileFlow.collectAsStateWithLifecycle()
|
||||
|
||||
val metadata = profile?.metadata?.asRecord()
|
||||
val nip05 = metadata?.nip05 ?: pubkeyObj.short()
|
||||
val nip05 = metadata?.nip05 ?: pubkey.short()
|
||||
val picture = metadata?.picture
|
||||
|
||||
val details = remember(profile) {
|
||||
@@ -159,7 +161,7 @@ fun ProfileScreen(pubkey: String) {
|
||||
scope.launch {
|
||||
try {
|
||||
val roomId =
|
||||
nostrViewModel.createChatRoom(listOf(pubkeyObj))
|
||||
chatViewModel.createChatRoom(listOf(pubkey))
|
||||
navigator.navigate(Screen.Chat(roomId))
|
||||
} catch (e: Exception) {
|
||||
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||
@@ -189,7 +191,7 @@ fun ProfileScreen(pubkey: String) {
|
||||
onClick = {
|
||||
val sendIntent = Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
putExtra(Intent.EXTRA_TEXT, pubkeyObj.toBech32())
|
||||
putExtra(Intent.EXTRA_TEXT, pubkey.toBech32())
|
||||
type = "text/plain"
|
||||
}
|
||||
val shareIntent = Intent.createChooser(sendIntent, null)
|
||||
|
||||
@@ -37,6 +37,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import kotlinx.coroutines.launch
|
||||
import su.reya.coop.LocalChatViewModel
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
@@ -49,13 +50,14 @@ fun RequestListScreen() {
|
||||
val navigator = LocalNavigator.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val nostrViewModel = LocalNostrViewModel.current
|
||||
val chatViewModel = LocalChatViewModel.current
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
val pullToRefreshState = rememberPullToRefreshState()
|
||||
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
val chatRooms by nostrViewModel.chatRooms.collectAsStateWithLifecycle()
|
||||
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
|
||||
|
||||
// Get all request rooms
|
||||
val requests = remember(chatRooms) {
|
||||
@@ -103,7 +105,7 @@ fun RequestListScreen() {
|
||||
onRefresh = {
|
||||
scope.launch {
|
||||
isRefreshing = true
|
||||
nostrViewModel.refreshChatRooms()
|
||||
chatViewModel.refreshChatRooms()
|
||||
isRefreshing = false
|
||||
}
|
||||
},
|
||||
|
||||
245
shared/src/commonMain/kotlin/su/reya/coop/ChatViewModel.kt
Normal file
245
shared/src/commonMain/kotlin/su/reya/coop/ChatViewModel.kt
Normal file
@@ -0,0 +1,245 @@
|
||||
package su.reya.coop
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.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.nostr.Nostr
|
||||
|
||||
data class ChatState(
|
||||
val isSyncing: Boolean = false,
|
||||
val isPartialProcessedGiftWrap: Boolean = false,
|
||||
)
|
||||
|
||||
class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
|
||||
private val _state = MutableStateFlow(ChatState())
|
||||
val state = _state.asStateFlow()
|
||||
|
||||
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
|
||||
val chatRooms = _chatRooms.asStateFlow()
|
||||
|
||||
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
||||
val newEvents = _newEvents.asSharedFlow()
|
||||
|
||||
private val _sentReports = MutableSharedFlow<Map<EventId, List<RelayUrl>>>()
|
||||
val sentReport = _sentReports.asSharedFlow()
|
||||
|
||||
val isSyncing = nostr.messages.messageSyncState.map { it.isSyncing }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
|
||||
val isPartialProcessedGiftWrap = _state.map { it.isPartialProcessedGiftWrap }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
nostr.waitUntilInitialized()
|
||||
|
||||
// Observe message sync progress
|
||||
launch {
|
||||
nostr.messages.messageSyncState.collect { state ->
|
||||
// When at least some messages are processed, allow UI to show the list
|
||||
if (state.processedCount > 0) {
|
||||
_state.update { it.copy(isPartialProcessedGiftWrap = true) }
|
||||
}
|
||||
|
||||
// Refresh UI every 10 messages OR when sync is fully done
|
||||
if (state.processedCount % 10 == 0 || !state.isSyncing) {
|
||||
refreshChatRooms()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Observe new messages
|
||||
launch {
|
||||
nostr.newEvents.collect { event ->
|
||||
val roomId = event.roomId()
|
||||
val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId }
|
||||
|
||||
if (existingRoom == null) {
|
||||
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@collect
|
||||
val newRoom = Room.new(event, currentUser)
|
||||
_chatRooms.update { (it + 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 = _chatRooms.value.firstOrNull { it.id == id }
|
||||
|
||||
// If the room already exists, return its ID
|
||||
if (existingRoom != null) {
|
||||
return existingRoom.id
|
||||
}
|
||||
|
||||
// Create a room from the rumor event
|
||||
val room = Room.new(rumor, currentUser)
|
||||
|
||||
// Update the chat rooms state
|
||||
_chatRooms.update { currentRooms ->
|
||||
(currentRooms + room).sortedDescending().toSet()
|
||||
}
|
||||
|
||||
return room.id
|
||||
} catch (e: Exception) {
|
||||
throw IllegalArgumentException("Failed to create room: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun getChatRoom(id: Long): Room? {
|
||||
return chatRooms.value.firstOrNull { it.id == id }
|
||||
}
|
||||
|
||||
suspend fun refreshChatRooms() {
|
||||
try {
|
||||
val rooms = nostr.messages.getChatRooms() ?: emptySet()
|
||||
_chatRooms.update { currentRooms ->
|
||||
val merged = currentRooms.associateBy { it.id }.toMutableMap()
|
||||
// Add or update rooms from the database
|
||||
rooms.forEach { room ->
|
||||
merged[room.id] = room
|
||||
}
|
||||
// Return as a sorted set to maintain UI consistency
|
||||
merged.values.sortedDescending().toSet()
|
||||
}
|
||||
} 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 = 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) {
|
||||
_chatRooms.update { currentRooms ->
|
||||
currentRooms.map { room ->
|
||||
if (room.id == roomId) {
|
||||
room.copy(
|
||||
lastMessage = newMessage.content(),
|
||||
createdAt = newMessage.createdAt()
|
||||
)
|
||||
} else {
|
||||
room
|
||||
}
|
||||
}.sortedDescending().toSet()
|
||||
}
|
||||
}
|
||||
|
||||
fun resetInternalState() {
|
||||
_chatRooms.value = emptySet()
|
||||
_state.update {
|
||||
it.copy(
|
||||
isPartialProcessedGiftWrap = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,9 @@ import kotlinx.coroutines.awaitCancellation
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
@@ -25,16 +23,10 @@ import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
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.RelayMetadata
|
||||
import rust.nostr.sdk.RelayUrl
|
||||
import rust.nostr.sdk.Tag
|
||||
import rust.nostr.sdk.Timestamp
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import su.reya.coop.nostr.Nostr
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
@@ -43,12 +35,10 @@ import kotlin.time.Duration.Companion.seconds
|
||||
data class NostrAppState(
|
||||
val isBusy: Boolean = false,
|
||||
val isRelayListEmpty: Boolean = false,
|
||||
val isSyncing: Boolean = false,
|
||||
val isPartialProcessedGiftWrap: Boolean = false,
|
||||
)
|
||||
|
||||
class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
|
||||
private val backgroundWorkTrigger = flow {
|
||||
private val alwaysRunTasks = flow {
|
||||
coroutineScope {
|
||||
val observerJob = launch { runObserver() }
|
||||
val batchingJob = launch { runMetadataBatching() }
|
||||
@@ -63,30 +53,16 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
|
||||
}
|
||||
|
||||
private val _appState = MutableStateFlow(NostrAppState())
|
||||
val appState: StateFlow<NostrAppState> = combine(
|
||||
_appState,
|
||||
nostr.messages.messageSyncState.map { it.isSyncing },
|
||||
backgroundWorkTrigger
|
||||
) { state, isSyncing, _ ->
|
||||
state.copy(isSyncing = isSyncing)
|
||||
}.stateIn(
|
||||
val appState: StateFlow<NostrAppState> =
|
||||
combine(_appState, alwaysRunTasks) { state, _ -> state }.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = NostrAppState()
|
||||
)
|
||||
|
||||
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
|
||||
val chatRooms = _chatRooms.asStateFlow()
|
||||
|
||||
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
|
||||
val contactList = _contactList.asStateFlow()
|
||||
|
||||
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
||||
val newEvents = _newEvents.asSharedFlow()
|
||||
|
||||
private val _sentReports = MutableSharedFlow<Map<EventId, List<RelayUrl>>>()
|
||||
val sentReport = _sentReports.asSharedFlow()
|
||||
|
||||
private val profilesMutex = Mutex()
|
||||
private val profiles = mutableMapOf<PublicKey, MutableStateFlow<Profile?>>()
|
||||
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
|
||||
@@ -94,12 +70,8 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
|
||||
|
||||
val isBusy = appState.map { it.isBusy }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
val isPartialProcessedGiftWrap = appState.map { it.isPartialProcessedGiftWrap }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
val isRelayListEmpty = appState.map { it.isRelayListEmpty }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
val isSyncing = appState.map { it.isSyncing }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val currentUserProfile = nostr.signer.publicKeyFlow
|
||||
@@ -138,39 +110,6 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
|
||||
}
|
||||
|
||||
private suspend fun runObserver() = coroutineScope {
|
||||
// Observe message sync progress
|
||||
launch {
|
||||
nostr.messages.messageSyncState.collect { state ->
|
||||
// When at least some messages are processed, allow UI to show the list
|
||||
if (state.processedCount > 0) {
|
||||
_appState.update { it.copy(isPartialProcessedGiftWrap = true) }
|
||||
}
|
||||
|
||||
// Refresh UI every 10 messages OR when sync is fully done
|
||||
if (state.processedCount % 10 == 0 || !state.isSyncing) {
|
||||
refreshChatRooms()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Observe new messages
|
||||
launch {
|
||||
nostr.newEvents.collect { event ->
|
||||
val roomId = event.roomId()
|
||||
val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId }
|
||||
|
||||
if (existingRoom == null) {
|
||||
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@collect
|
||||
val newRoom = Room.new(event, currentUser)
|
||||
_chatRooms.update { (it + newRoom).sortedDescending().toSet() }
|
||||
} else {
|
||||
updateRoomList(roomId, event)
|
||||
}
|
||||
|
||||
_newEvents.emit(event)
|
||||
}
|
||||
}
|
||||
|
||||
// Observe contact list updates
|
||||
launch {
|
||||
nostr.profiles.contactListUpdates.collect { contacts ->
|
||||
@@ -245,13 +184,6 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
|
||||
val currentUser = nostr.signer.getPublicKeyAsync()
|
||||
|
||||
if (currentUser != null) {
|
||||
// Get chat rooms
|
||||
val rooms = nostr.messages.getChatRooms() ?: emptySet()
|
||||
if (rooms.isNotEmpty()) {
|
||||
mergeChatRooms(rooms)
|
||||
_appState.update { it.copy(isPartialProcessedGiftWrap = true) }
|
||||
}
|
||||
|
||||
// Get all metadata for the current user
|
||||
nostr.profiles.getUserMetadata()
|
||||
|
||||
@@ -294,11 +226,9 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
|
||||
}
|
||||
|
||||
fun resetInternalState() {
|
||||
_chatRooms.value = emptySet()
|
||||
_contactList.value = emptySet()
|
||||
_appState.update {
|
||||
it.copy(
|
||||
isPartialProcessedGiftWrap = false,
|
||||
isRelayListEmpty = false,
|
||||
)
|
||||
}
|
||||
@@ -475,164 +405,6 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
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 = _chatRooms.value.firstOrNull { it.id == id }
|
||||
|
||||
// If the room already exists, return its ID
|
||||
if (existingRoom != null) {
|
||||
return existingRoom.id
|
||||
}
|
||||
|
||||
// Create a room from the rumor event
|
||||
val room = Room.new(rumor, currentUser)
|
||||
|
||||
// Update the chat rooms state
|
||||
_chatRooms.update { currentRooms ->
|
||||
(currentRooms + room).sortedDescending().toSet()
|
||||
}
|
||||
|
||||
return room.id
|
||||
} catch (e: Exception) {
|
||||
throw IllegalArgumentException("Failed to create room: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun getChatRoom(id: Long): Room? {
|
||||
return chatRooms.value.firstOrNull { it.id == id }
|
||||
}
|
||||
|
||||
private fun mergeChatRooms(rooms: Set<Room>) {
|
||||
_chatRooms.update { currentRooms ->
|
||||
val merged = currentRooms.associateBy { it.id }.toMutableMap()
|
||||
// Add or update rooms from the database
|
||||
rooms.forEach { room ->
|
||||
merged[room.id] = room
|
||||
}
|
||||
// Return as a sorted set to maintain UI consistency
|
||||
merged.values.sortedDescending().toSet()
|
||||
}
|
||||
}
|
||||
|
||||
fun getChatRooms() {
|
||||
viewModelScope.launch {
|
||||
val rooms = nostr.messages.getChatRooms() ?: emptySet()
|
||||
mergeChatRooms(rooms)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun refreshChatRooms() {
|
||||
try {
|
||||
val rooms = nostr.messages.getChatRooms() ?: emptySet()
|
||||
mergeChatRooms(rooms)
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> {
|
||||
try {
|
||||
return nostr.messages.getChatRoomMessages(roomId)
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
fun chatRoomConnect(roomId: Long) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
||||
val members = room.members
|
||||
|
||||
nostr.messages.chatRoomConnect(members.toList())
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage(roomId: Long, message: String, replies: List<EventId> = emptyList()) {
|
||||
if (message.isEmpty()) {
|
||||
showError("Message cannot be empty")
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
||||
nostr.messages.sendMessage(
|
||||
to = room.members,
|
||||
content = message,
|
||||
subject = room.subject,
|
||||
replies = replies,
|
||||
onRumorCreated = { event ->
|
||||
updateRoomList(roomId, event)
|
||||
viewModelScope.launch { _newEvents.emit(event) }
|
||||
},
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendFileMessage(
|
||||
roomId: Long,
|
||||
file: ByteArray?,
|
||||
contentType: String? = "image/jpeg",
|
||||
replies: List<EventId> = emptyList()
|
||||
) {
|
||||
if (file == null) return
|
||||
|
||||
try {
|
||||
val uri = 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) {
|
||||
_chatRooms.update { currentRooms ->
|
||||
currentRooms.map { room ->
|
||||
if (room.id == roomId) {
|
||||
room.copy(
|
||||
lastMessage = newMessage.content(),
|
||||
createdAt = newMessage.createdAt()
|
||||
)
|
||||
} else {
|
||||
room
|
||||
}
|
||||
}.sortedDescending().toSet()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun searchByAddress(query: String): PublicKey? {
|
||||
try {
|
||||
return nostr.profiles.searchByAddress(query)
|
||||
|
||||
Reference in New Issue
Block a user