Release feed, profile screen, other UI fixes

This commit is contained in:
franzap
2026-07-12 20:26:52 -03:00
parent d9a04a3773
commit 81aefb75c0
10 changed files with 968 additions and 131 deletions
@@ -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(
+39 -4
View File
@@ -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<String> = setOf(Catalog.relay),
): Flow<QueryState> = query(listOf(filter), type, cachedFor, relays)
fun query(
filters: List<Filter>,
type: QueryType,
cachedFor: Duration? = null,
relays: Set<String> = setOf(Catalog.relay),
): Flow<QueryState>
fun refreshConnections()
@@ -53,9 +68,10 @@ class PurpleQuartzCatalogRepository(context: Context) : CatalogRepository {
).also { client = it }
override fun query(
filter: Filter,
filters: List<Filter>,
type: QueryType,
cachedFor: Duration?,
relays: Set<String>,
): Flow<QueryState> {
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)
+21 -2
View File
@@ -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://")
@@ -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)}"
@@ -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,
)
}
@@ -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<ProfileInfo?>(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"
+313 -40
View File
@@ -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<ReleaseFeedEntry>,
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 {
+237 -55
View File
@@ -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<String, AppInfo> = emptyMap(),
val stacksLoading: Boolean = true,
val stacksError: String? = null,
val releases: List<ReleaseInfo> = emptyList(),
val releaseApps: Map<String, AppInfo> = 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<ReleaseFeedEntry> = 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<String, AppInfo>()
private val releasesByApp = mutableMapOf<String, ReleaseInfo>()
private var nextUntil: Long? = null
private val boundaryEventIds = mutableSetOf<String>()
private var releaseLookupKeys: Set<String> = 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<AppInfo> {
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<AppInfo>) {
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<HomeUiState> = _uiState.asStateFlow()
private var stackPreviewJob: Job? = null
private var releaseAppsJob: Job? = null
private var searchJob: Job? = null
private var stackAddresses: List<String> = emptyList()
private var releaseIdentifiers: Set<String> = 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<ReleaseInfo>) {
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<String>(PROFILE_PUBKEY_ARGUMENT))
private val _uiState = MutableStateFlow(ProfileUiState(pubkey = pubkey))
val uiState: StateFlow<ProfileUiState> = _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
+8 -4
View File
@@ -4,19 +4,19 @@
<string name="clear_search">Clear search</string>
<string name="curated_stacks">Curated stacks</string>
<string name="latest_releases">Latest releases</string>
<string name="loading_stacks">Loading community stacks…</string>
<string name="no_releases">No releases found.</string>
<string name="no_stacks">No stacks found.</string>
<string name="loading_stack">Loading stack…</string>
<string name="stack_not_found">Stack not found.</string>
<string name="apps">Apps</string>
<plurals name="curated_apps">
<item quantity="one">%1$d curated app</item>
<item quantity="other">%1$d curated apps</item>
</plurals>
<string name="loading_stack_apps">Loading apps in this stack…</string>
<string name="no_stack_apps">No apps found in this stack.</string>
<string name="loading_app">Loading app…</string>
<string name="app_not_found">App not found.</string>
<string name="profile_not_found">Profile not found.</string>
<string name="profile_apps">Apps by this profile</string>
<string name="no_profile_apps">No apps published by this profile.</string>
<string name="published_by">Published by · %1$s…</string>
<string name="latest_release">Latest release</string>
<string name="version">Version:</string>
@@ -25,8 +25,12 @@
<string name="app_id">App ID</string>
<string name="author">Author</string>
<string name="release_date">Release date</string>
<string name="website">Website</string>
<string name="nip05">NIP-05</string>
<string name="public_key">Public key</string>
<string name="not_available">Not available</string>
<string name="app_icon_description">%1$s icon</string>
<string name="screenshot_description">%1$s screenshot</string>
<string name="profile_avatar_description">%1$s avatar</string>
<string name="profile_banner_description">Profile banner</string>
</resources>
@@ -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<Filter>()
val appFlows = mutableListOf<MutableSharedFlow<QueryState>>()
val repository = FakeCatalogRepository(
localQuery = { filter ->
if (filter.kinds == listOf(Catalog.appKind)) {
appFilters += filter
MutableSharedFlow<QueryState>().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<Filter>()
val appFlows = mutableListOf<MutableSharedFlow<QueryState>>()
val repository = FakeCatalogRepository(
localQuery = { filter ->
if (filter.kinds == listOf(Catalog.appKind)) {
appFilters += filter
MutableSharedFlow<QueryState>().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<QueryState>()
val releaseFlow = MutableSharedFlow<QueryState>()
var releaseFilters = emptyList<Filter>()
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<Filter>()
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<QueryState> = { emptyFlow() },
private val remoteQuery: (Filter) -> Flow<QueryState> = { emptyFlow() },
private val localQueries: ((List<Filter>) -> Flow<QueryState>)? = null,
private val remoteQueries: ((List<Filter>) -> Flow<QueryState>)? = null,
) : CatalogRepository {
override fun query(
filter: Filter,
filters: List<Filter>,
type: QueryType,
cachedFor: Duration?,
relays: Set<String>,
): Flow<QueryState> = 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<Array<String>> = emptyArray(),
): Event = Event(
id = id,
pubKey = "1".repeat(64),
createdAt = 1_750_000_000,
pubKey = pubKey,
createdAt = createdAt,
kind = kind,
tags = tags,
content = "",