diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 1646718..53a76dd 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -50,10 +50,10 @@ import su.reya.coop.screens.UpdateProfileScreen import su.reya.coop.screens.chat.ChatScreen import su.reya.coop.viewmodel.AccountViewModel import su.reya.coop.viewmodel.ChatViewModel -import su.reya.coop.viewmodel.NostrViewModel +import su.reya.coop.viewmodel.ProfileCache -val LocalNostrViewModel = staticCompositionLocalOf { - error("No NostrViewModel provided") +val LocalProfileCache = staticCompositionLocalOf { + error("No ProfileCache provided") } val LocalChatViewModel = staticCompositionLocalOf { @@ -80,7 +80,7 @@ val LocalScanResult = staticCompositionLocalOf { @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable fun App( - nostrViewModel: NostrViewModel, + profileCache: ProfileCache, chatViewModel: ChatViewModel, accountViewModel: AccountViewModel, ) { @@ -130,7 +130,7 @@ fun App( } } launch { - nostrViewModel.errorEvents.collect { message -> + profileCache.errorEvents.collect { message -> snackbarHostState.showSnackbar(message) } } @@ -174,7 +174,7 @@ fun App( motionScheme = MotionScheme.expressive(), ) { CompositionLocalProvider( - LocalNostrViewModel provides nostrViewModel, + LocalProfileCache provides profileCache, LocalChatViewModel provides chatViewModel, LocalAccountViewModel provides accountViewModel, LocalSnackbarHostState provides snackbarHostState, diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt index 273b77e..5a5e9b8 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -16,7 +16,7 @@ import su.reya.coop.nostr.NostrManager import su.reya.coop.repository.MediaRepository import su.reya.coop.viewmodel.AccountViewModel import su.reya.coop.viewmodel.ChatViewModel -import su.reya.coop.viewmodel.NostrViewModel +import su.reya.coop.viewmodel.ProfileCache import kotlin.system.exitProcess class MainActivity : ComponentActivity() { @@ -24,11 +24,12 @@ class MainActivity : ComponentActivity() { val externalSignerLauncher = ExternalSignerLauncher() } + private val profileCache by lazy { ProfileCache(NostrManager.instance) } + private val factory by lazy { object : ViewModelProvider.Factory { private val storage = AppStore(this@MainActivity) private val mediaRepository = MediaRepository() - private val nostrViewModel = NostrViewModel(NostrManager.instance) private val chatViewModel = ChatViewModel(NostrManager.instance, mediaRepository) private val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher) @@ -37,7 +38,6 @@ class MainActivity : ComponentActivity() { override fun create(modelClass: Class): T { return when { - modelClass.isAssignableFrom(NostrViewModel::class.java) -> nostrViewModel modelClass.isAssignableFrom(ChatViewModel::class.java) -> chatViewModel modelClass.isAssignableFrom(AccountViewModel::class.java) -> accountViewModel else -> throw IllegalArgumentException("Unknown ViewModel class") @@ -46,7 +46,6 @@ class MainActivity : ComponentActivity() { } } - private val nostrViewModel: NostrViewModel by viewModels { factory } private val chatViewModel: ChatViewModel by viewModels { factory } private val accountViewModel: AccountViewModel by viewModels { factory } @@ -92,7 +91,7 @@ class MainActivity : ComponentActivity() { setContent { App( - nostrViewModel = nostrViewModel, + profileCache = profileCache, chatViewModel = chatViewModel, accountViewModel = accountViewModel, ) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt index e20f1df..26a9a1a 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt @@ -61,7 +61,7 @@ import rust.nostr.sdk.PublicKey import su.reya.coop.LocalAccountViewModel import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalNostrViewModel +import su.reya.coop.LocalProfileCache import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen import su.reya.coop.shared.Avatar @@ -72,7 +72,7 @@ import su.reya.coop.short fun ContactListScreen() { val navigator = LocalNavigator.current val snackbarHostState = LocalSnackbarHostState.current - val nostrViewModel = LocalNostrViewModel.current + val profileCache = LocalProfileCache.current val accountViewModel = LocalAccountViewModel.current val chatViewModel = LocalChatViewModel.current @@ -223,7 +223,7 @@ fun ContactListScreen() { @Composable fun AddContactDialog(onDismissRequest: () -> Unit) { val snackbarHostState = LocalSnackbarHostState.current - val nostrViewModel = LocalNostrViewModel.current + val profileCache = LocalProfileCache.current val accountViewModel = LocalAccountViewModel.current val focusRequester = remember { FocusRequester() } var contact by remember { mutableStateOf("") } @@ -326,8 +326,8 @@ fun ContactListItem( onClick: () -> Unit, onLongClick: () -> Unit, ) { - val nostrViewModel = LocalNostrViewModel.current - val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } + val profileCache = LocalProfileCache.current + val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) } val profile by profileFlow.collectAsStateWithLifecycle(initialValue = null) SegmentedListItem( diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index 3e0aaa2..b51adc3 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -92,7 +92,7 @@ import rust.nostr.sdk.PublicKey import su.reya.coop.LocalAccountViewModel import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalNostrViewModel +import su.reya.coop.LocalProfileCache import su.reya.coop.LocalScanResult import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Room @@ -609,15 +609,15 @@ fun HomeScreen() { @Composable fun NewRequests(requests: List) { val navigator = LocalNavigator.current - val nostrViewModel = LocalNostrViewModel.current + val profileCache = LocalProfileCache.current val total = requests.size val firstRoom = requests.getOrNull(0) val secondRoom = requests.getOrNull(1) - val firstRoomState by (firstRoom as Room).uiStateFlow(nostrViewModel) + val firstRoomState by (firstRoom as Room).uiStateFlow(profileCache) .collectAsStateWithLifecycle(RoomUiState()) - val secondRoomState by (secondRoom ?: firstRoom).uiStateFlow(nostrViewModel) + val secondRoomState by (secondRoom ?: firstRoom).uiStateFlow(profileCache) .collectAsStateWithLifecycle(RoomUiState()) val supportingText = when { @@ -689,8 +689,8 @@ fun NewRequests(requests: List) { @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun ChatRoom(room: Room, onClick: () -> Unit) { - val nostrViewModel = LocalNostrViewModel.current - val roomState by room.uiStateFlow(nostrViewModel) + val profileCache = LocalProfileCache.current + val roomState by room.uiStateFlow(profileCache) .collectAsStateWithLifecycle(RoomUiState()) ListItem( @@ -737,7 +737,7 @@ fun BottomMenuList( onDismiss: (suspend () -> Unit) -> Unit ) { val navigator = LocalNavigator.current - val nostrViewModel = LocalNostrViewModel.current + val profileCache = LocalProfileCache.current val chatViewModel = LocalChatViewModel.current val accountViewModel = LocalAccountViewModel.current diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt index cd0242d..92efb52 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt @@ -58,7 +58,7 @@ import rust.nostr.sdk.PublicKey import su.reya.coop.LocalAccountViewModel import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalNostrViewModel +import su.reya.coop.LocalProfileCache import su.reya.coop.LocalScanResult import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen @@ -72,7 +72,7 @@ fun NewChatScreen() { val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current val qrScanResult = LocalScanResult.current - val nostrViewModel = LocalNostrViewModel.current + val profileCache = LocalProfileCache.current val accountViewModel = LocalAccountViewModel.current val chatViewModel = LocalChatViewModel.current @@ -294,8 +294,8 @@ fun ReceiverChip( pubkey: PublicKey, onRemove: () -> Unit ) { - val nostrViewModel = LocalNostrViewModel.current - val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } + val profileCache = LocalProfileCache.current + val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) } val profile by profileFlow.collectAsState(initial = null) CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) { @@ -376,8 +376,8 @@ fun ContactListItem( onClick: () -> Unit, onLongClick: () -> Unit ) { - val nostrViewModel = LocalNostrViewModel.current - val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } + val profileCache = LocalProfileCache.current + val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) } val profile by profileFlow.collectAsState(initial = null) SegmentedListItem( diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt index 35cd311..6f669be 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt @@ -46,7 +46,7 @@ import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalNostrViewModel +import su.reya.coop.LocalProfileCache import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen import su.reya.coop.shared.Avatar @@ -61,11 +61,11 @@ fun ProfileScreen(pubkey: String) { val context = LocalContext.current val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current - val nostrViewModel = LocalNostrViewModel.current + val profileCache = LocalProfileCache.current val chatViewModel = LocalChatViewModel.current val scope = rememberCoroutineScope() - val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } + val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) } val profile by profileFlow.collectAsStateWithLifecycle() val metadata = profile?.metadata?.asRecord() diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt index 6689755..93b8f4d 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt @@ -67,7 +67,7 @@ import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.RelayMetadata import rust.nostr.sdk.RelayUrl import su.reya.coop.LocalNavigator -import su.reya.coop.LocalNostrViewModel +import su.reya.coop.LocalProfileCache import su.reya.coop.LocalAccountViewModel import su.reya.coop.LocalSnackbarHostState @@ -75,7 +75,7 @@ import su.reya.coop.LocalSnackbarHostState @Composable fun RelayScreen() { val navigator = LocalNavigator.current - val nostrViewModel = LocalNostrViewModel.current + val profileCache = LocalProfileCache.current val accountViewModel = LocalAccountViewModel.current val snackbarHostState = LocalSnackbarHostState.current diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatComponents.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatComponents.kt index 39ac829..970a8b1 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatComponents.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatComponents.kt @@ -32,7 +32,7 @@ import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey import rust.nostr.sdk.Timestamp import su.reya.coop.LocalAccountViewModel -import su.reya.coop.LocalNostrViewModel +import su.reya.coop.LocalProfileCache import su.reya.coop.Room import su.reya.coop.humanReadable import su.reya.coop.shared.Avatar @@ -44,14 +44,14 @@ import su.reya.coop.short fun ScreenerCard(room: Room) { val pubkey = room.members.firstOrNull() ?: return - val nostrViewModel = LocalNostrViewModel.current + val profileCache = LocalProfileCache.current val accountViewModel = LocalAccountViewModel.current var isContact by remember { mutableStateOf(false) } var mutualContacts by remember { mutableStateOf>(emptySet()) } var lastActivity by remember { mutableStateOf(null) } - val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } + val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) } val profile by profileFlow.collectAsStateWithLifecycle() LaunchedEffect(pubkey) { diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt index 0f06483..71e258e 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt @@ -68,7 +68,7 @@ import rust.nostr.sdk.UnsignedEvent import su.reya.coop.LocalAccountViewModel import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalNostrViewModel +import su.reya.coop.LocalProfileCache import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Room import su.reya.coop.RoomUiState @@ -88,7 +88,7 @@ fun ChatScreen( val context = LocalContext.current val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current - val nostrViewModel = LocalNostrViewModel.current + val profileCache = LocalProfileCache.current val chatViewModel = LocalChatViewModel.current val scope = rememberCoroutineScope() val listState = rememberLazyListState() @@ -116,7 +116,7 @@ fun ChatScreen( return } - val roomState by (room as Room).uiStateFlow(nostrViewModel, currentUser?.publicKey) + val roomState by (room as Room).uiStateFlow(profileCache, currentUser?.publicKey) .collectAsStateWithLifecycle(RoomUiState()) var text by remember { mutableStateOf("") } var loading by remember { mutableStateOf(true) } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt index ce71bc4..65d1a98 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt @@ -11,7 +11,7 @@ import kotlinx.datetime.toLocalDateTime import rust.nostr.sdk.PublicKey import rust.nostr.sdk.Timestamp import rust.nostr.sdk.UnsignedEvent -import su.reya.coop.viewmodel.NostrViewModel +import su.reya.coop.viewmodel.ProfileCache import kotlin.time.Clock import kotlin.time.Instant @@ -72,7 +72,7 @@ data class RoomUiState( ) fun Room.uiStateFlow( - nostrViewModel: NostrViewModel, + profileCache: ProfileCache, currentUser: PublicKey? = null ): Flow { val displayMembers = if (isGroup()) members.take(2) else members.take(1) @@ -81,7 +81,7 @@ fun Room.uiStateFlow( return flowOf(RoomUiState(name = subject.sanitizeName(), isGroup = isGroup())) } - return combine(displayMembers.map { nostrViewModel.getMetadata(it) }) { profiles -> + return combine(displayMembers.map { profileCache.getMetadata(it) }) { profiles -> val names = profiles.mapIndexed { i, profile -> profile?.name?.sanitizeName() ?: displayMembers[i].short() } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ProfileCache.kt similarity index 61% rename from shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt rename to shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ProfileCache.kt index 93d21ab..f6a6324 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ProfileCache.kt @@ -1,7 +1,8 @@ package su.reya.coop.viewmodel -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow @@ -16,33 +17,36 @@ import su.reya.coop.Profile import su.reya.coop.nostr.Nostr import kotlin.time.Duration.Companion.milliseconds -class NostrViewModel(private val nostr: Nostr) : ViewModel(), ErrorHost by createErrorHost() { +/** + * Application-level singleton cache for Nostr user profiles. + * + * Replaces [NostrViewModel] as a non-ViewModel component with its own lifecycle scope. + * This is appropriate because profile caching is not screen-specific — it's a shared + * concern used by every screen that displays user metadata. + * + * Long-running tasks ([runObserver], [runMetadataBatching]) run in a dedicated + * [CoroutineScope] that outlives any individual screen, ensuring continuous operation + * regardless of navigation. + */ +class ProfileCache( + private val nostr: Nostr, +) : ErrorHost by createErrorHost() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val profilesMutex = Mutex() private val profiles = mutableMapOf>() private val metadataRequestChannel = Channel(Channel.UNLIMITED) private val seenPublicKeys = mutableSetOf() init { - // Launch continuous background observers - viewModelScope.launch { runObserver() } - viewModelScope.launch { runMetadataBatching() } - - // Automatically reconnect bootstrap relays - reconnect() - - // Get all local stored metadata - getCacheMetadata() - } - - private fun reconnect() { - viewModelScope.launch { + scope.launch { runObserver() } + scope.launch { runMetadataBatching() } + scope.launch { nostr.waitUntilInitialized() - nostr.reconnect() + loadCacheMetadata() } } private suspend fun runObserver() = coroutineScope { - // Observe metadata updates launch { nostr.profiles.metadataUpdates.collect { (pubkey, metadata) -> updateMetadata(pubkey, Profile(pubkey, metadata)) @@ -57,7 +61,6 @@ class NostrViewModel(private val nostr: Nostr) : ViewModel(), ErrorHost by creat val firstKey = metadataRequestChannel.receive() val batch = mutableSetOf(firstKey) - // Collect up to 10 keys that arrive within 500ms while (batch.size < 10) { val nextKey = withTimeoutOrNull(500.milliseconds) { metadataRequestChannel.receive() } @@ -69,18 +72,14 @@ class NostrViewModel(private val nostr: Nostr) : ViewModel(), ErrorHost by creat } } - private fun getCacheMetadata() { - viewModelScope.launch { - // Wait until the client is ready - nostr.waitUntilInitialized() - val cache = nostr.profiles.getAllCacheMetadata() + private suspend fun loadCacheMetadata() { + val cache = nostr.profiles.getAllCacheMetadata() - profilesMutex.withLock { - cache.forEach { (pubkey, metadata) -> - val profile = Profile(pubkey, metadata) - profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile - seenPublicKeys.add(pubkey) - } + profilesMutex.withLock { + cache.forEach { (pubkey, metadata) -> + val profile = Profile(pubkey, metadata) + profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile + seenPublicKeys.add(pubkey) } } } @@ -97,11 +96,13 @@ class NostrViewModel(private val nostr: Nostr) : ViewModel(), ErrorHost by creat } } + /** + * Returns a [StateFlow] for the profile of the given [pubkey]. + * Triggers a metadata fetch if the profile is not yet cached. + */ fun getMetadata(pubkey: PublicKey): StateFlow { val flow = profiles.getOrPut(pubkey) { MutableStateFlow(null) } if (flow.value == null) requestMetadata(pubkey) - return flow.asStateFlow() } - }