chore: clean up codebase #42

Merged
reya merged 6 commits from cleanup into master 2026-07-13 01:04:16 +00:00
11 changed files with 75 additions and 75 deletions
Showing only changes of commit 77ff35fbf7 - Show all commits

View File

@@ -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<NostrViewModel> {
error("No NostrViewModel provided")
val LocalProfileCache = staticCompositionLocalOf<ProfileCache> {
error("No ProfileCache provided")
}
val LocalChatViewModel = staticCompositionLocalOf<ChatViewModel> {
@@ -80,7 +80,7 @@ val LocalScanResult = staticCompositionLocalOf<QrScanResult> {
@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,

View File

@@ -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 <T : ViewModel> create(modelClass: Class<T>): T {
return when {
modelClass.isAssignableFrom(NostrViewModel::class.java) -> nostrViewModel
modelClass.isAssignableFrom(ChatViewModel::class.java) -> chatViewModel
modelClass.isAssignableFrom(AccountViewModel::class.java) -> accountViewModel
else -> throw IllegalArgumentException("Unknown ViewModel class")
@@ -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,
)

View File

@@ -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(

View File

@@ -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<Room>) {
val navigator = LocalNavigator.current
val nostrViewModel = LocalNostrViewModel.current
val profileCache = LocalProfileCache.current
val total = requests.size
val firstRoom = requests.getOrNull(0)
val secondRoom = requests.getOrNull(1)
val firstRoomState by (firstRoom as Room).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<Room>) {
@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

View File

@@ -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(

View File

@@ -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()

View File

@@ -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

View File

@@ -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<Set<PublicKey>>(emptySet()) }
var lastActivity by remember { mutableStateOf<Timestamp?>(null) }
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) }
val profile by profileFlow.collectAsStateWithLifecycle()
LaunchedEffect(pubkey) {

View File

@@ -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) }

View File

@@ -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<RoomUiState> {
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()
}

View File

@@ -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<PublicKey, MutableStateFlow<Profile?>>()
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
private val seenPublicKeys = mutableSetOf<PublicKey>()
init {
// Launch continuous background observers
viewModelScope.launch { runObserver() }
viewModelScope.launch { runMetadataBatching() }
// Automatically reconnect bootstrap relays
reconnect()
// Get all local stored metadata
getCacheMetadata()
}
private fun reconnect() {
viewModelScope.launch {
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<Profile?> {
val flow = profiles.getOrPut(pubkey) { MutableStateFlow(null) }
if (flow.value == null) requestMetadata(pubkey)
return flow.asStateFlow()
}
}