From 81aefb75c0f34cc12af05d46252ab468ca53832d Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Sun, 12 Jul 2026 20:26:52 -0300 Subject: [PATCH] Release feed, profile screen, other UI fixes --- .../kotlin/dev/zapstore/app/ScreensTest.kt | 54 +++ src/main/kotlin/dev/zapstore/app/Catalog.kt | 43 ++- .../kotlin/dev/zapstore/app/Components.kt | 23 +- .../kotlin/dev/zapstore/app/MainActivity.kt | 43 +++ src/main/kotlin/dev/zapstore/app/Markdown.kt | 3 + .../dev/zapstore/app/ProfileComponent.kt | 29 +- src/main/kotlin/dev/zapstore/app/Screens.kt | 353 ++++++++++++++++-- .../kotlin/dev/zapstore/app/ViewModels.kt | 292 ++++++++++++--- src/main/res/values/strings.xml | 12 +- .../kotlin/dev/zapstore/app/ViewModelsTest.kt | 247 +++++++++++- 10 files changed, 968 insertions(+), 131 deletions(-) diff --git a/src/androidTest/kotlin/dev/zapstore/app/ScreensTest.kt b/src/androidTest/kotlin/dev/zapstore/app/ScreensTest.kt index c22b215..fc50d81 100644 --- a/src/androidTest/kotlin/dev/zapstore/app/ScreensTest.kt +++ b/src/androidTest/kotlin/dev/zapstore/app/ScreensTest.kt @@ -86,6 +86,60 @@ class ScreensTest { assertEquals("https://example.com/repo", openedUrl) } + + @Test + fun appProfileCallbackIsWired() { + var openedProfile: String? = null + val app = AppInfo(event(kind = Catalog.appKind)) + + composeRule.setContent { + ZapstoreTheme { + AppCard( + app = app, + repository = null, + onClick = {}, + onProfileClick = { openedProfile = app.event.pubKey }, + ) + } + } + + composeRule.onNodeWithTag("profile:${app.event.pubKey}").performClick() + + assertEquals(app.event.pubKey, openedProfile) + } + + @Test + fun releaseFeedShowsItsLoadingFooter() { + val release = ReleaseInfo( + event( + id = "release-id", + kind = Catalog.releaseKind, + tags = arrayOf(arrayOf("i", "zap")), + ), + ) + + composeRule.setContent { + ZapstoreTheme { + HomeScreen( + state = HomeUiState( + releaseFeed = ReleaseFeedUiState( + entries = listOf(ReleaseFeedEntry(release)), + initialLoading = false, + loadingMore = true, + ), + ), + onSearchQueryChanged = {}, + onSearchSubmitted = {}, + onSearchCleared = {}, + onStackClick = {}, + onAppClick = { _, _ -> }, + ) + } + } + + composeRule.onNodeWithTag("release:release-id").assertIsDisplayed() + composeRule.onNodeWithTag("releaseLoading").assertIsDisplayed() + } } private fun event( diff --git a/src/main/kotlin/dev/zapstore/app/Catalog.kt b/src/main/kotlin/dev/zapstore/app/Catalog.kt index e175152..74f4691 100644 --- a/src/main/kotlin/dev/zapstore/app/Catalog.kt +++ b/src/main/kotlin/dev/zapstore/app/Catalog.kt @@ -14,21 +14,36 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow import okhttp3.OkHttpClient +import org.json.JSONObject import kotlin.time.Duration object Catalog { const val relay = "wss://relay.zapstore.dev" + const val profileRelay = "wss://relay.vertexlab.io" + const val assetKind = 3_063 const val releaseKind = 30_063 const val appKind = 32_267 const val appStackKind = 30_267 + const val profileKind = 0 const val communityPubkey = "acfeaea6e51420e8068fac446ca9d17d7a9ef6a5d20d93894e50fee3d4902a84" + val releaseKinds = listOf(releaseKind, assetKind) } +val PROFILE_RELAYS = setOf(Catalog.relay, Catalog.profileRelay) + interface CatalogRepository { fun query( filter: Filter, type: QueryType, cachedFor: Duration? = null, + relays: Set = setOf(Catalog.relay), + ): Flow = query(listOf(filter), type, cachedFor, relays) + + fun query( + filters: List, + type: QueryType, + cachedFor: Duration? = null, + relays: Set = setOf(Catalog.relay), ): Flow fun refreshConnections() @@ -53,9 +68,10 @@ class PurpleQuartzCatalogRepository(context: Context) : CatalogRepository { ).also { client = it } override fun query( - filter: Filter, + filters: List, type: QueryType, cachedFor: Duration?, + relays: Set, ): Flow { require(type == QueryType.LocalAndRemote || cachedFor == null) { "cachedFor is supported only for local-and-remote queries" @@ -64,7 +80,7 @@ class PurpleQuartzCatalogRepository(context: Context) : CatalogRepository { val source = when (type) { QueryType.Local -> QuerySource.Local QueryType.LocalAndRemote -> QuerySource.LocalAndRemote( - relays = setOf(Catalog.relay.normalizeRelayUrl()), + relays = relays.map(String::normalizeRelayUrl).toSet(), mode = RemoteMode.Stream, cachedFor = cachedFor, ) @@ -74,7 +90,7 @@ class PurpleQuartzCatalogRepository(context: Context) : CatalogRepository { ) } - return client().query(filter = filter, source = source) + return client().query(filters = filters, source = source) } override fun refreshConnections() { @@ -95,7 +111,11 @@ data class AppInfo(val event: Event) { } data class ReleaseInfo(val event: Event) { - val appIdentifier: String? get() = event.tagValue("i") + val appIdentifier: String? + get() = event.tagValue("i") + ?: event.tagValue("d") + ?.substringBefore("@") + ?.takeIf(String::isNotBlank) val version: String get() = event.tagValue("version") ?: "Unknown version" val channel: String? get() = event.tagValue("c") val notes: String get() = event.content @@ -112,6 +132,21 @@ data class AppCoordinate(val author: String, val identifier: String) { val address: String get() = "${Catalog.appKind}:$author:$identifier" } +data class ProfileInfo( + val event: Event, +) { + val name: String? get() = metadataValue("name") + val displayName: String? get() = metadataValue("display_name") + val about: String? get() = metadataValue("about") + val picture: String? get() = metadataValue("picture") ?: metadataValue("image") + val banner: String? get() = metadataValue("banner") + val website: String? get() = metadataValue("website") + val nip05: String? get() = metadataValue("nip05") + + private fun metadataValue(key: String): String? = + runCatching { JSONObject(event.content).optString(key).takeIf(String::isNotBlank) }.getOrNull() +} + fun Event.tagValue(name: String): String? = tags.firstOrNull { it.firstOrNull() == name }?.getOrNull(1) diff --git a/src/main/kotlin/dev/zapstore/app/Components.kt b/src/main/kotlin/dev/zapstore/app/Components.kt index 834e955..476e8b9 100644 --- a/src/main/kotlin/dev/zapstore/app/Components.kt +++ b/src/main/kotlin/dev/zapstore/app/Components.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -66,6 +67,7 @@ fun AppCard( repository: CatalogRepository? = null, modifier: Modifier = Modifier, release: ReleaseInfo? = null, + onProfileClick: (() -> Unit)? = null, ) { Column( modifier = modifier @@ -109,11 +111,12 @@ fun AppCard( pubkey = app.event.pubKey, repository = repository, modifier = Modifier.padding(top = 10.dp), + onClick = onProfileClick, ) if (app.summary.isNotBlank()) { Spacer(Modifier.height(10.dp)) - MarkdownText( - value = app.summary, + Text( + text = ZapMarkdown.parse(app.summary).text, color = ZapMuted, style = MaterialTheme.typography.bodyMedium, maxLines = 3, @@ -191,5 +194,21 @@ fun StatusText( ) } +@Composable +fun LoadingIndicator( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + color = ZapMuted, + strokeWidth = 2.dp, + ) + } +} + fun isHttpUrl(value: String): Boolean = value.startsWith("https://") || value.startsWith("http://") diff --git a/src/main/kotlin/dev/zapstore/app/MainActivity.kt b/src/main/kotlin/dev/zapstore/app/MainActivity.kt index da0f6dd..92ca79d 100644 --- a/src/main/kotlin/dev/zapstore/app/MainActivity.kt +++ b/src/main/kotlin/dev/zapstore/app/MainActivity.kt @@ -80,12 +80,16 @@ class MainActivity : ComponentActivity() { onSearchQueryChanged = viewModel::onSearchQueryChanged, onSearchSubmitted = viewModel::submitSearch, onSearchCleared = viewModel::clearSearch, + onLoadMoreReleases = viewModel::loadMoreReleases, onStackClick = { stackId -> navController.navigate(stackRoute(stackId)) }, onAppClick = { identifier, author -> navController.navigate(appRoute(identifier, author)) }, + onProfileClick = { pubkey -> + navController.navigate(profileRoute(pubkey)) + }, ) } @@ -105,6 +109,9 @@ class MainActivity : ComponentActivity() { onAppClick = { identifier, author -> navController.navigate(appRoute(identifier, author)) }, + onProfileClick = { pubkey -> + navController.navigate(profileRoute(pubkey)) + }, ) } @@ -135,6 +142,38 @@ class MainActivity : ComponentActivity() { } }, repository = repository, + onProfileClick = { pubkey -> + navController.navigate(profileRoute(pubkey)) + }, + ) + } + + composable( + route = PROFILE_ROUTE, + arguments = listOf( + navArgument(PROFILE_PUBKEY_ARGUMENT) { type = NavType.StringType }, + ), + ) { + val viewModel: ProfileViewModel = viewModel( + factory = profileViewModelFactory(repository), + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + ProfileScreen( + state = state, + repository = repository, + onAppClick = { identifier, author -> + navController.navigate(appRoute(identifier, author)) + }, + onLoadMoreReleases = viewModel::loadMoreReleases, + onOpenUrl = { value -> + if (isHttpUrl(value)) { + runCatching { + context.startActivity( + Intent(Intent.ACTION_VIEW, value.toUri()), + ) + } + } + }, ) } } @@ -153,6 +192,7 @@ private const val HOME_ROUTE = "home" private const val STACK_ROUTE = "stack/{$STACK_ID_ARGUMENT}" private const val APP_ROUTE = "app/{$APP_IDENTIFIER_ARGUMENT}?$APP_AUTHOR_ARGUMENT={$APP_AUTHOR_ARGUMENT}" +private const val PROFILE_ROUTE = "profile/{$PROFILE_PUBKEY_ARGUMENT}" private const val NAVIGATION_TRANSITION_DURATION = 150 private fun stackRoute(stackId: String): String = @@ -164,3 +204,6 @@ private fun appRoute(identifier: String, author: String?): String = buildString append("?").append(APP_AUTHOR_ARGUMENT).append("=").append(Uri.encode(it)) } } + +private fun profileRoute(pubkey: String): String = + "profile/${Uri.encode(pubkey)}" diff --git a/src/main/kotlin/dev/zapstore/app/Markdown.kt b/src/main/kotlin/dev/zapstore/app/Markdown.kt index 15e5fb3..92199b7 100644 --- a/src/main/kotlin/dev/zapstore/app/Markdown.kt +++ b/src/main/kotlin/dev/zapstore/app/Markdown.kt @@ -4,6 +4,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.LinkAnnotation @@ -162,6 +163,7 @@ fun MarkdownText( maxLines: Int = Int.MAX_VALUE, overflow: TextOverflow = TextOverflow.Clip, onOpenUrl: ((String) -> Unit)? = null, + modifier: Modifier = Modifier, ) { val annotated = remember(value, onOpenUrl) { ZapMarkdown.parse(value, onOpenUrl) @@ -172,5 +174,6 @@ fun MarkdownText( style = style, maxLines = maxLines, overflow = overflow, + modifier = modifier, ) } diff --git a/src/main/kotlin/dev/zapstore/app/ProfileComponent.kt b/src/main/kotlin/dev/zapstore/app/ProfileComponent.kt index 28c929a..4acd50a 100644 --- a/src/main/kotlin/dev/zapstore/app/ProfileComponent.kt +++ b/src/main/kotlin/dev/zapstore/app/ProfileComponent.kt @@ -1,6 +1,7 @@ package dev.zapstore.app import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -15,6 +16,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -22,7 +24,6 @@ import coil3.compose.AsyncImage import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import kotlinx.coroutines.flow.collect -import org.json.JSONObject import kotlin.time.Duration.Companion.hours @Composable @@ -30,14 +31,16 @@ fun ProfileComponent( pubkey: String, repository: CatalogRepository?, modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, ) { val profile by produceState(initialValue = null, pubkey, repository) { if (repository == null) return@produceState repository.query( - Filter(authors = listOf(pubkey), kinds = listOf(PROFILE_KIND), limit = 1), + Filter(authors = listOf(pubkey), kinds = listOf(Catalog.profileKind), limit = 1), type = QueryType.LocalAndRemote, cachedFor = PROFILE_CACHE_DURATION, + relays = PROFILE_RELAYS, ).collect { state -> value = state.items.maxByOrNull(Event::createdAt)?.let(::ProfileInfo) } @@ -51,7 +54,9 @@ fun ProfileComponent( val pictureUrl = profile?.picture?.takeIf(::isHttpUrl) Row( - modifier = modifier, + modifier = modifier + .testTag("profile:$pubkey") + .then(onClick?.let { Modifier.clickable(onClick = it) } ?: Modifier), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), ) { @@ -82,24 +87,6 @@ fun ProfileComponent( ) } } - -private data class ProfileInfo( - val name: String?, - val displayName: String?, - val picture: String?, -) { - constructor(event: Event) : this( - name = runCatching { JSONObject(event.content).optString("name") }.getOrNull(), - displayName = runCatching { JSONObject(event.content).optString("display_name") }.getOrNull(), - picture = runCatching { - JSONObject(event.content).optString("picture").ifBlank { - JSONObject(event.content).optString("image") - } - }.getOrNull(), - ) -} - -private const val PROFILE_KIND = 0 private val PROFILE_CACHE_DURATION = 6.hours private val BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" diff --git a/src/main/kotlin/dev/zapstore/app/Screens.kt b/src/main/kotlin/dev/zapstore/app/Screens.kt index 8152a28..b0d4f11 100644 --- a/src/main/kotlin/dev/zapstore/app/Screens.kt +++ b/src/main/kotlin/dev/zapstore/app/Screens.kt @@ -13,13 +13,18 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions @@ -29,9 +34,14 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.pluralStringResource @@ -55,8 +65,20 @@ fun HomeScreen( onSearchCleared: () -> Unit, onStackClick: (String) -> Unit, onAppClick: (identifier: String, author: String) -> Unit, + onProfileClick: (String) -> Unit = {}, + onLoadMoreReleases: () -> Unit = {}, modifier: Modifier = Modifier, ) { + val listState = rememberLazyListState() + val latestReleases = stringResource(R.string.latest_releases) + val noReleases = stringResource(R.string.no_releases) + LoadMoreReleasesWhenNearEnd( + listState = listState, + entries = state.releaseFeed.entries, + loading = state.releaseFeed.loadingMore, + canLoadMore = state.releaseFeed.canLoadMore, + onLoadMore = onLoadMoreReleases, + ) LazyColumn( modifier = modifier .fillMaxSize() @@ -64,6 +86,7 @@ fun HomeScreen( .windowInsetsPadding(WindowInsets.safeDrawing), contentPadding = PaddingValues(start = 20.dp, top = 8.dp, end = 20.dp, bottom = 32.dp), verticalArrangement = Arrangement.spacedBy(12.dp), + state = listState, ) { item { OutlinedTextField( @@ -103,6 +126,7 @@ fun HomeScreen( AppCard( app = app, onClick = { onAppClick(app.identifier, app.event.pubKey) }, + onProfileClick = { onProfileClick(app.event.pubKey) }, repository = repository, modifier = Modifier.testTag("searchResult:${app.address}"), ) @@ -132,29 +156,21 @@ fun HomeScreen( } } - state.stacksLoading -> StatusText(stringResource(R.string.loading_stacks)) + state.stacksLoading -> LoadingIndicator(Modifier.padding(vertical = 12.dp)) state.stacksError != null -> StatusText(state.stacksError) else -> StatusText(stringResource(R.string.no_stacks)) } } - item { - SectionTitle( - value = stringResource(R.string.latest_releases), - modifier = Modifier.padding(top = 8.dp), - ) - } - item { StatusText(state.releasesMessage) } - items(state.releases, key = { it.event.id }) { release -> - val app = release.appIdentifier?.let(state.releaseApps::get) ?: AppInfo(release.event) - AppCard( - app = app, - release = release, - onClick = { onAppClick(app.identifier, app.event.pubKey) }, - repository = repository, - modifier = Modifier.testTag("release:${release.event.id}"), - ) - } + releaseFeed( + state = state.releaseFeed, + title = latestReleases, + emptyMessage = noReleases, + repository = repository, + onAppClick = onAppClick, + onProfileClick = onProfileClick, + modifier = Modifier, + ) } } @@ -163,6 +179,7 @@ fun StackDetailScreen( state: StackDetailUiState, repository: CatalogRepository? = null, onAppClick: (identifier: String, author: String) -> Unit, + onProfileClick: (String) -> Unit = {}, modifier: Modifier = Modifier, ) { LazyColumn( @@ -176,13 +193,14 @@ fun StackDetailScreen( val stack = state.stack if (stack == null) { item { - Text( - text = when { - state.stackLoading -> stringResource(R.string.loading_stack) - else -> stringResource(R.string.stack_not_found) - }, - style = MaterialTheme.typography.headlineSmall, - ) + if (state.stackLoading) { + LoadingIndicator(Modifier.padding(vertical = 20.dp)) + } else { + Text( + text = stringResource(R.string.stack_not_found), + style = MaterialTheme.typography.headlineSmall, + ) + } } state.error?.let { error -> item { StatusText(error) } } return@LazyColumn @@ -218,19 +236,18 @@ fun StackDetailScreen( AppCard( app = app, onClick = { onAppClick(app.identifier, app.event.pubKey) }, + onProfileClick = { onProfileClick(app.event.pubKey) }, repository = repository, modifier = Modifier.testTag("stackApp:${app.address}"), ) } if (state.appsByAddress.isEmpty()) { item { - StatusText( - if (state.appsLoading) { - stringResource(R.string.loading_stack_apps) - } else { - stringResource(R.string.no_stack_apps) - }, - ) + if (state.appsLoading) { + LoadingIndicator(Modifier.padding(vertical = 12.dp)) + } else { + StatusText(stringResource(R.string.no_stack_apps)) + } } } state.error?.let { error -> item { StatusText(error) } } @@ -242,6 +259,7 @@ fun AppDetailScreen( state: AppDetailUiState, onOpenUrl: (String) -> Unit, repository: CatalogRepository? = null, + onProfileClick: (String) -> Unit = {}, modifier: Modifier = Modifier, ) { LazyColumn( @@ -263,14 +281,14 @@ fun AppDetailScreen( .background(ZapSurfaceVariant), contentAlignment = Alignment.Center, ) { - Text( - text = if (state.appLoading) { - stringResource(R.string.loading_app) - } else { - stringResource(R.string.app_not_found) - }, - color = ZapMuted, - ) + if (state.appLoading) { + LoadingIndicator() + } else { + Text( + text = stringResource(R.string.app_not_found), + color = ZapMuted, + ) + } } } state.error?.let { error -> item { StatusText(error) } } @@ -313,6 +331,7 @@ fun AppDetailScreen( ProfileComponent( pubkey = app.event.pubKey, repository = repository, + onClick = { onProfileClick(app.event.pubKey) }, ) } @@ -390,6 +409,7 @@ fun AppDetailScreen( release = state.release, onOpenUrl = onOpenUrl, repository = repository, + onProfileClick = { onProfileClick(app.event.pubKey) }, modifier = Modifier.padding(top = 8.dp), ) } @@ -397,6 +417,257 @@ fun AppDetailScreen( } } +@Composable +fun ProfileScreen( + state: ProfileUiState, + onOpenUrl: (String) -> Unit, + repository: CatalogRepository? = null, + onAppClick: (identifier: String, author: String) -> Unit = { _, _ -> }, + onLoadMoreReleases: () -> Unit = {}, + modifier: Modifier = Modifier, +) { + val listState = rememberLazyListState() + val profileApps = stringResource(R.string.profile_apps) + val noProfileApps = stringResource(R.string.no_profile_apps) + LoadMoreReleasesWhenNearEnd( + listState = listState, + entries = state.releaseFeed.entries, + loading = state.releaseFeed.loadingMore, + canLoadMore = state.releaseFeed.canLoadMore, + onLoadMore = onLoadMoreReleases, + ) + val bannerParallax = if (listState.firstVisibleItemIndex == 0) { + listState.firstVisibleItemScrollOffset * 0.25f + } else { + 0f + } + + LazyColumn( + modifier = modifier + .fillMaxSize() + .background(ZapBackground) + .navigationBarsPadding(), + state = listState, + contentPadding = PaddingValues(bottom = 36.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + val profile = state.profile + if (profile == null) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .height(100.dp) + .clip(RoundedCornerShape(16.dp)) + .background(ZapSurfaceVariant), + contentAlignment = Alignment.Center, + ) { + if (state.profileLoading) { + LoadingIndicator() + } else { + Text( + text = stringResource(R.string.profile_not_found), + color = ZapMuted, + ) + } + } + } + state.error?.let { error -> item { StatusText(error) } } + return@LazyColumn + } + + item { + Box( + modifier = Modifier + .fillMaxWidth() + .height(240.dp) + .background(ZapSurfaceVariant), + ) { + profile.banner?.takeIf(::isHttpUrl)?.let { bannerUrl -> + AsyncImage( + model = bannerUrl, + contentDescription = stringResource(R.string.profile_banner_description), + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .height(280.dp) + .graphicsLayer { translationY = bannerParallax } + .background(ZapSurfaceVariant), + ) + } + Row( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ProfileAvatar( + name = profile.displayName ?: profile.name ?: state.pubkey, + pictureUrl = profile.picture, + size = 84.dp, + ) + Spacer(Modifier.width(16.dp)) + Column { + Text( + text = profile.displayName ?: profile.name ?: state.pubkey, + style = MaterialTheme.typography.displaySmall, + ) + profile.name?.takeIf { it != profile.displayName }?.let { + StatusText("@$it") + } + } + } + } + } + + profile.about?.takeIf(String::isNotBlank)?.let { about -> + item { + MarkdownText( + value = about, + color = ZapMuted, + style = MaterialTheme.typography.bodyLarge, + onOpenUrl = onOpenUrl, + modifier = Modifier.padding(horizontal = 20.dp), + ) + } + } + + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .clip(RoundedCornerShape(12.dp)) + .background(ZapSurface) + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + profile.website?.let { + InfoRow(stringResource(R.string.website), it, link = it, onOpenUrl = onOpenUrl) + } + profile.nip05?.let { + InfoRow(stringResource(R.string.nip05), it, onOpenUrl = onOpenUrl) + } + InfoRow(stringResource(R.string.public_key), state.pubkey, onOpenUrl = onOpenUrl) + } + } + + releaseFeed( + state = state.releaseFeed, + title = profileApps, + emptyMessage = noProfileApps, + repository = repository, + onAppClick = onAppClick, + modifier = Modifier.padding(horizontal = 20.dp), + ) + state.error?.let { error -> item { StatusText(error) } } + } +} + +private fun LazyListScope.releaseFeed( + state: ReleaseFeedUiState, + title: String, + emptyMessage: String, + repository: CatalogRepository?, + onAppClick: (identifier: String, author: String) -> Unit, + onProfileClick: (String) -> Unit = {}, + modifier: Modifier, +) { + item { + SectionTitle( + value = title, + modifier = modifier.padding(top = 4.dp), + ) + } + when { + state.entries.isEmpty() && state.initialLoading -> item { + LoadingIndicator(modifier.padding(vertical = 12.dp)) + } + + state.entries.isEmpty() -> item { + StatusText(state.error ?: emptyMessage, modifier) + } + + else -> { + state.error?.let { error -> item { StatusText(error, modifier) } } + items(state.entries, key = { "app:${it.app.event.id}" }) { entry -> + val app = entry.app + AppCard( + app = app, + release = entry.release, + onClick = { onAppClick(app.identifier, app.event.pubKey) }, + onProfileClick = { onProfileClick(app.event.pubKey) }, + repository = repository, + modifier = modifier.testTag("app:${app.event.id}"), + ) + } + if (state.loadingMore) { + item(key = "release-loading") { + LoadingIndicator( + modifier + .testTag("releaseLoading") + .padding(vertical = 8.dp), + ) + } + } + } + } +} + +@Composable +private fun LoadMoreReleasesWhenNearEnd( + listState: LazyListState, + entries: List, + loading: Boolean, + canLoadMore: Boolean, + onLoadMore: () -> Unit, +) { + val prefetchKeys = remember(entries) { + entries.takeLast(RELEASE_PREFETCH_THRESHOLD) + .mapTo(mutableSetOf()) { "app:${it.app.event.id}" } + } + val nearEnd by remember(listState, prefetchKeys) { + derivedStateOf { + listState.layoutInfo.visibleItemsInfo.any { it.key in prefetchKeys } + } + } + LaunchedEffect(nearEnd, loading, canLoadMore) { + if (nearEnd && !loading && canLoadMore) onLoadMore() + } +} + +private const val RELEASE_PREFETCH_THRESHOLD = 3 + +@Composable +private fun ProfileAvatar( + name: String, + pictureUrl: String?, + size: androidx.compose.ui.unit.Dp, +) { + Box( + modifier = Modifier + .size(size) + .clip(CircleShape) + .background(ZapSurfaceVariant), + contentAlignment = Alignment.Center, + ) { + Text( + text = name.take(1).uppercase(), + style = MaterialTheme.typography.headlineMedium, + color = ZapMuted, + ) + pictureUrl?.takeIf(::isHttpUrl)?.let { url -> + AsyncImage( + model = url, + contentDescription = stringResource(R.string.profile_avatar_description, name), + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .clip(CircleShape), + ) + } + } +} + @Composable private fun VersionRow(release: ReleaseInfo) { Row( @@ -425,6 +696,7 @@ private fun AppInfoCard( release: ReleaseInfo?, onOpenUrl: (String) -> Unit, repository: CatalogRepository?, + onProfileClick: () -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -455,6 +727,7 @@ private fun AppInfoCard( pubkey = app.event.pubKey, repository = repository, modifier = Modifier.weight(1f), + onClick = onProfileClick, ) } release?.let { diff --git a/src/main/kotlin/dev/zapstore/app/ViewModels.kt b/src/main/kotlin/dev/zapstore/app/ViewModels.kt index 7c29863..6349d54 100644 --- a/src/main/kotlin/dev/zapstore/app/ViewModels.kt +++ b/src/main/kotlin/dev/zapstore/app/ViewModels.kt @@ -13,12 +13,14 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch const val STACK_ID_ARGUMENT = "stackId" const val APP_IDENTIFIER_ARGUMENT = "identifier" const val APP_AUTHOR_ARGUMENT = "author" +const val PROFILE_PUBKEY_ARGUMENT = "pubkey" data class HomeUiState( val searchQuery: String = "", @@ -30,12 +32,187 @@ data class HomeUiState( val stackApps: Map = emptyMap(), val stacksLoading: Boolean = true, val stacksError: String? = null, - val releases: List = emptyList(), - val releaseApps: Map = emptyMap(), - val releasesLoading: Boolean = true, - val releasesMessage: String = "Connecting to relay.zapstore.dev", + val releaseFeed: ReleaseFeedUiState = ReleaseFeedUiState(), ) +data class ReleaseFeedEntry( + val app: AppInfo, + val release: ReleaseInfo? = null, +) + +data class ReleaseFeedUiState( + val entries: List = emptyList(), + val initialLoading: Boolean = true, + val loadingMore: Boolean = false, + val canLoadMore: Boolean = true, + val error: String? = null, +) + +/** + * App-first feed with release-ranked presentation. + * + * Kind 32267 app events are the identity and pagination source, so exhausting the + * feed exhausts the app catalog. Kinds 30063 and 3063 only provide each app's + * latest-version metadata and sort key; they can never create a feed row. + */ +private class ReleaseFeedLoader( + private val repository: CatalogRepository, + private val scope: kotlinx.coroutines.CoroutineScope, + private val author: String? = null, + private val onStateChanged: (ReleaseFeedUiState) -> Unit, +) { + private val appsByAddress = linkedMapOf() + private val releasesByApp = mutableMapOf() + private var nextUntil: Long? = null + private val boundaryEventIds = mutableSetOf() + private var releaseLookupKeys: Set = emptySet() + private var releaseJob: Job? = null + private var requestInFlight = false + private var state = ReleaseFeedUiState() + + fun loadNextPage() { + if (requestInFlight || !state.canLoadMore) return + requestInFlight = true + + updateState( + state.copy( + initialLoading = appsByAddress.isEmpty(), + loadingMore = appsByAddress.isNotEmpty(), + error = null, + ), + ) + val pageUntil = nextUntil + val pageLimit = APP_PAGE_SIZE + boundaryEventIds.size + scope.launch { + var receivedTerminalState = false + repository.query( + Filter( + authors = author?.let(::listOf), + kinds = listOf(Catalog.appKind), + limit = pageLimit, + until = pageUntil, + ), + type = QueryType.LocalAndRemote, + ).takeWhile { queryState -> + val page = queryState.items + .filter { it.kind == Catalog.appKind } + .map(::AppInfo) + .sortedByDescending { it.event.createdAt } + .distinctBy { it.event.id } + page.forEach { app -> + val existing = appsByAddress[app.address] + if ( + existing == null || + app.event.createdAt > existing.event.createdAt || + app.event.createdAt == existing.event.createdAt && app.event.id < existing.event.id + ) { + appsByAddress[app.address] = app + } + } + publishEntries() + + val terminal = !queryState.sync.isLoading || queryState.error != null + if (!terminal && appsByAddress.isNotEmpty()) { + updateState(state.copy(initialLoading = false, loadingMore = true)) + } else if (terminal) { + receivedTerminalState = true + requestInFlight = false + page.minOfOrNull { it.event.createdAt }?.let { oldestTimestamp -> + val idsAtBoundary = page + .asSequence() + .filter { it.event.createdAt == oldestTimestamp } + .mapTo(mutableSetOf()) { it.event.id } + if (pageUntil != oldestTimestamp) boundaryEventIds.clear() + boundaryEventIds += idsAtBoundary + nextUntil = oldestTimestamp + } + updateState( + state.copy( + initialLoading = false, + loadingMore = false, + canLoadMore = queryState.error == null && page.size >= pageLimit, + error = queryState.error?.message, + ), + ) + } + !terminal + }.collect { + // State is handled in takeWhile so the terminal emission can stop this live query. + } + if (!receivedTerminalState) { + requestInFlight = false + updateState(state.copy(initialLoading = false, loadingMore = false, canLoadMore = false)) + } + } + } + + private fun publishEntries() { + val entries = appsByAddress.values + .sortedWith( + compareByDescending { + releasesByApp[it.address]?.event?.createdAt ?: Long.MIN_VALUE + }.thenByDescending { it.event.createdAt } + .thenBy { it.address }, + ) + .map { app -> + ReleaseFeedEntry( + app = app, + release = releasesByApp[app.address], + ) + } + updateState(state.copy(entries = entries)) + observeReleases(entries.map(ReleaseFeedEntry::app)) + } + + private fun observeReleases(apps: List) { + val lookupKeys = apps.map(AppInfo::address).toSet() + if (lookupKeys == releaseLookupKeys) return + releaseLookupKeys = lookupKeys + releaseJob?.cancel() + + if (apps.isEmpty()) return + + val releaseFilters = apps.map { app -> + Filter( + authors = listOf(app.event.pubKey), + kinds = Catalog.releaseKinds, + tags = mapOf("i" to listOf(app.identifier)), + limit = 1, + ) + } + releaseJob = scope.launch { + repository.query( + releaseFilters, + type = QueryType.LocalAndRemote, + ).collect { queryState -> + queryState.items + .filter { it.kind in Catalog.releaseKinds } + .map(::ReleaseInfo) + .forEach { release -> + val identifier = release.appIdentifier ?: return@forEach + val address = "${Catalog.appKind}:${release.event.pubKey}:$identifier" + if (address in appsByAddress) { + val existing = releasesByApp[address] + if (existing == null || existing.event.createdAt < release.event.createdAt) { + releasesByApp[address] = release + } + } + } + publishEntries() + } + } + } + + private fun updateState(value: ReleaseFeedUiState) { + state = value + onStateChanged(value) + } + + private companion object { + const val APP_PAGE_SIZE = 20 + } +} + class HomeViewModel( private val repository: CatalogRepository, private val savedStateHandle: SavedStateHandle, @@ -46,14 +223,15 @@ class HomeViewModel( val uiState: StateFlow = _uiState.asStateFlow() private var stackPreviewJob: Job? = null - private var releaseAppsJob: Job? = null private var searchJob: Job? = null private var stackAddresses: List = emptyList() - private var releaseIdentifiers: Set = emptySet() + private val releaseFeed = ReleaseFeedLoader(repository, viewModelScope) { feed -> + _uiState.update { it.copy(releaseFeed = feed) } + } init { observeStacks() - observeLatestReleases() + releaseFeed.loadNextPage() } fun onSearchQueryChanged(value: String) { @@ -120,6 +298,8 @@ class HomeViewModel( } } + fun loadMoreReleases() = releaseFeed.loadNextPage() + private fun observeStacks() { viewModelScope.launch { repository.query( @@ -172,54 +352,6 @@ class HomeViewModel( } } - private fun observeLatestReleases() { - viewModelScope.launch { - repository.query( - Filter(kinds = listOf(Catalog.releaseKind), limit = 20), - type = QueryType.LocalAndRemote, - ).collect { state -> - val releases = state.items.map(::ReleaseInfo) - .sortedByDescending { it.event.createdAt } - .distinctBy { it.appIdentifier ?: it.event.id } - .take(10) - _uiState.update { - it.copy( - releases = releases, - releasesLoading = releases.isEmpty() && state.sync.isLoading, - releasesMessage = state.error?.message ?: "${releases.size} recently updated apps", - ) - } - observeReleaseApps(releases) - } - } - } - - private fun observeReleaseApps(releases: List) { - val identifiers = releases.mapNotNull(ReleaseInfo::appIdentifier).toSet() - if (identifiers == releaseIdentifiers) return - releaseIdentifiers = identifiers - releaseAppsJob?.cancel() - if (identifiers.isEmpty()) { - _uiState.update { it.copy(releaseApps = emptyMap()) } - return - } - - releaseAppsJob = viewModelScope.launch { - repository.query( - Filter( - kinds = listOf(Catalog.appKind), - tags = mapOf("d" to identifiers.toList()), - limit = identifiers.size * 3, - ), - type = QueryType.LocalAndRemote, - ).collect { state -> - _uiState.update { - it.copy(releaseApps = state.items.map(::AppInfo).associateBy(AppInfo::identifier)) - } - } - } - } - private companion object { const val SEARCH_QUERY_KEY = "searchQuery" const val MIN_SEARCH_LENGTH = 3 @@ -307,6 +439,52 @@ data class AppDetailUiState( val error: String? = null, ) +data class ProfileUiState( + val profile: ProfileInfo? = null, + val pubkey: String = "", + val releaseFeed: ReleaseFeedUiState = ReleaseFeedUiState(), + val profileLoading: Boolean = true, + val error: String? = null, +) + +class ProfileViewModel( + private val repository: CatalogRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + private val pubkey = requireNotNull(savedStateHandle.get(PROFILE_PUBKEY_ARGUMENT)) + private val _uiState = MutableStateFlow(ProfileUiState(pubkey = pubkey)) + val uiState: StateFlow = _uiState.asStateFlow() + private val releaseFeed = ReleaseFeedLoader(repository, viewModelScope, pubkey) { feed -> + _uiState.update { it.copy(releaseFeed = feed) } + } + + init { + observeProfile() + releaseFeed.loadNextPage() + } + + private fun observeProfile() { + viewModelScope.launch { + repository.query( + Filter(authors = listOf(pubkey), kinds = listOf(Catalog.profileKind), limit = 1), + type = QueryType.LocalAndRemote, + relays = PROFILE_RELAYS, + ).collect { state -> + val profile = state.items.maxByOrNull { it.createdAt }?.let(::ProfileInfo) + _uiState.update { + it.copy( + profile = profile, + profileLoading = profile == null && state.sync.isLoading, + error = state.error?.message, + ) + } + } + } + } + + fun loadMoreReleases() = releaseFeed.loadNextPage() +} + class AppDetailViewModel( private val repository: CatalogRepository, savedStateHandle: SavedStateHandle, @@ -379,5 +557,9 @@ fun appDetailViewModelFactory(repository: CatalogRepository): ViewModelProvider. initializer { AppDetailViewModel(repository, createSavedStateHandle()) } } +fun profileViewModelFactory(repository: CatalogRepository): ViewModelProvider.Factory = viewModelFactory { + initializer { ProfileViewModel(repository, createSavedStateHandle()) } +} + private val QuerySync.isLoading: Boolean get() = this == QuerySync.Connecting || this == QuerySync.CatchingUp diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 7ddf05d..8670a71 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -4,19 +4,19 @@ Clear search Curated stacks Latest releases - Loading community stacks… + No releases found. No stacks found. - Loading stack… Stack not found. Apps %1$d curated app %1$d curated apps - Loading apps in this stack… No apps found in this stack. - Loading app… App not found. + Profile not found. + Apps by this profile + No apps published by this profile. Published by · %1$s… Latest release Version: @@ -25,8 +25,12 @@ App ID Author Release date + Website + NIP-05 + Public key Not available %1$s icon %1$s screenshot %1$s avatar + Profile banner diff --git a/src/test/kotlin/dev/zapstore/app/ViewModelsTest.kt b/src/test/kotlin/dev/zapstore/app/ViewModelsTest.kt index 9fda6a2..3b89f77 100644 --- a/src/test/kotlin/dev/zapstore/app/ViewModelsTest.kt +++ b/src/test/kotlin/dev/zapstore/app/ViewModelsTest.kt @@ -90,6 +90,238 @@ class ViewModelsTest { assertEquals(1, appFlows.last().subscriptionCount.value) } + @Test + fun `release feed pages through apps and stops after a short page`() = runTest { + val appFilters = mutableListOf() + val appFlows = mutableListOf>() + val repository = FakeCatalogRepository( + localQuery = { filter -> + if (filter.kinds == listOf(Catalog.appKind)) { + appFilters += filter + MutableSharedFlow().also(appFlows::add) + } else { + emptyFlow() + } + }, + ) + val viewModel = HomeViewModel(repository, SavedStateHandle()) + runCurrent() + + appFlows.single().emit( + queryState( + *(0 until 20).map { index -> + event( + id = "app-$index", + kind = Catalog.appKind, + createdAt = 1_750_000_000L - index, + tags = arrayOf(arrayOf("d", "app-$index")), + ) + }.toTypedArray(), + event( + id = "not-an-app", + kind = Catalog.releaseKind, + createdAt = 1_750_000_001L, + tags = arrayOf(arrayOf("i", "not-an-app")), + ), + ), + ) + runCurrent() + + assertEquals(20, viewModel.uiState.value.releaseFeed.entries.size) + assertEquals(true, viewModel.uiState.value.releaseFeed.canLoadMore) + + viewModel.loadMoreReleases() + runCurrent() + + assertEquals(2, appFilters.size) + assertEquals(1_749_999_981L, appFilters.last().until) + assertEquals(21, appFilters.last().limit) + + appFlows.last().emit( + queryState( + event( + id = "app-last", + kind = Catalog.appKind, + createdAt = 1_749_999_979L, + tags = arrayOf(arrayOf("d", "last-app")), + ), + ), + ) + runCurrent() + + assertEquals(21, viewModel.uiState.value.releaseFeed.entries.size) + assertEquals(false, viewModel.uiState.value.releaseFeed.canLoadMore) + } + + @Test + fun `release feed does not skip apps tied at a page boundary`() = runTest { + val appFilters = mutableListOf() + val appFlows = mutableListOf>() + val repository = FakeCatalogRepository( + localQuery = { filter -> + if (filter.kinds == listOf(Catalog.appKind)) { + appFilters += filter + MutableSharedFlow().also(appFlows::add) + } else { + emptyFlow() + } + }, + ) + val viewModel = HomeViewModel(repository, SavedStateHandle()) + runCurrent() + + val timestamp = 1_750_000_000L + val apps = (0 until 25).map { index -> + event( + id = "tied-app-$index", + kind = Catalog.appKind, + createdAt = timestamp, + tags = arrayOf(arrayOf("d", "tied-app-$index")), + ) + } + appFlows.single().emit(queryState(*apps.take(20).toTypedArray())) + runCurrent() + + viewModel.loadMoreReleases() + runCurrent() + + assertEquals(timestamp, appFilters.last().until) + assertEquals(40, appFilters.last().limit) + + appFlows.last().emit(queryState(*apps.toTypedArray())) + runCurrent() + + assertEquals(25, viewModel.uiState.value.releaseFeed.entries.size) + assertEquals(false, viewModel.uiState.value.releaseFeed.canLoadMore) + } + + @Test + fun `release feed outputs apps ordered by latest release or asset`() = runTest { + val appFlow = MutableSharedFlow() + val releaseFlow = MutableSharedFlow() + var releaseFilters = emptyList() + val repository = FakeCatalogRepository( + localQuery = { filter -> + if (filter.kinds == listOf(Catalog.appKind)) appFlow else emptyFlow() + }, + localQueries = { filters -> + if (filters.size == 1) { + if (filters.single().kinds == listOf(Catalog.appKind)) appFlow else emptyFlow() + } else { + releaseFilters = filters + releaseFlow + } + }, + ) + val viewModel = HomeViewModel(repository, SavedStateHandle()) + runCurrent() + + appFlow.emit( + queryState( + event( + id = "app-one", + kind = Catalog.appKind, + createdAt = 100, + tags = arrayOf(arrayOf("d", "one")), + ), + event( + id = "app-two", + kind = Catalog.appKind, + createdAt = 90, + tags = arrayOf(arrayOf("d", "two")), + ), + event( + id = "app-without-release", + kind = Catalog.appKind, + createdAt = 500, + tags = arrayOf(arrayOf("d", "without-release")), + ), + ), + ) + runCurrent() + + assertEquals(3, releaseFilters.size) + assertEquals(true, releaseFilters.all { it.kinds == Catalog.releaseKinds }) + assertEquals(true, releaseFilters.all { it.limit == 1 }) + + releaseFlow.emit( + queryState( + event( + id = "release-one", + kind = Catalog.releaseKind, + createdAt = 200, + tags = arrayOf( + arrayOf("i", "one"), + arrayOf("version", "1.0"), + ), + ), + event( + id = "release-two", + kind = Catalog.releaseKind, + createdAt = 250, + tags = arrayOf( + arrayOf("i", "two"), + arrayOf("version", "2.0"), + ), + ), + event( + id = "asset-two", + kind = Catalog.assetKind, + createdAt = 300, + tags = arrayOf( + arrayOf("i", "two"), + arrayOf("version", "2.1"), + ), + ), + event( + id = "not-a-release", + kind = Catalog.appStackKind, + createdAt = 1_000, + tags = arrayOf(arrayOf("i", "one")), + ), + ), + ) + runCurrent() + + val entries = viewModel.uiState.value.releaseFeed.entries + assertEquals(listOf("two", "one", "without-release"), entries.map { it.app.identifier }) + assertEquals(listOf(Catalog.assetKind, Catalog.releaseKind, null), entries.map { it.release?.event?.kind }) + assertEquals(true, entries.all { it.app.event.kind == Catalog.appKind }) + } + + @Test + fun `legacy release d tag resolves its app identifier`() { + val release = ReleaseInfo( + event( + kind = Catalog.releaseKind, + tags = arrayOf(arrayOf("d", "dev.example.app@1.2.3")), + ), + ) + + assertEquals("dev.example.app", release.appIdentifier) + } + + @Test + fun `profile release feed filters apps by profile pubkey`() = runTest { + val filters = mutableListOf() + val pubkey = "b".repeat(64) + val repository = FakeCatalogRepository( + localQuery = { filter -> + filters += filter + emptyFlow() + }, + ) + + ProfileViewModel( + repository, + SavedStateHandle(mapOf(PROFILE_PUBKEY_ARGUMENT to pubkey)), + ) + runCurrent() + + val appFilter = filters.single { it.kinds == listOf(Catalog.appKind) } + assertEquals(listOf(pubkey), appFilter.authors) + } + private fun stackState(identifier: String): QueryState = queryState( event( id = "stack-$identifier", @@ -105,16 +337,19 @@ class ViewModelsTest { private class FakeCatalogRepository( private val localQuery: (Filter) -> Flow = { emptyFlow() }, private val remoteQuery: (Filter) -> Flow = { emptyFlow() }, + private val localQueries: ((List) -> Flow)? = null, + private val remoteQueries: ((List) -> Flow)? = null, ) : CatalogRepository { override fun query( - filter: Filter, + filters: List, type: QueryType, cachedFor: Duration?, + relays: Set, ): Flow = when (type) { QueryType.Local, QueryType.LocalAndRemote, - -> localQuery(filter) - QueryType.Remote -> remoteQuery(filter) + -> localQueries?.invoke(filters) ?: filters.singleOrNull()?.let(localQuery) ?: emptyFlow() + QueryType.Remote -> remoteQueries?.invoke(filters) ?: filters.singleOrNull()?.let(remoteQuery) ?: emptyFlow() } override fun refreshConnections() = Unit @@ -128,12 +363,14 @@ private fun queryState(vararg events: Event): QueryState = private fun event( id: String = "a".repeat(64), + pubKey: String = "1".repeat(64), kind: Int, + createdAt: Long = 1_750_000_000, tags: Array> = emptyArray(), ): Event = Event( id = id, - pubKey = "1".repeat(64), - createdAt = 1_750_000_000, + pubKey = pubKey, + createdAt = createdAt, kind = kind, tags = tags, content = "",