mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 20:48:24 +00:00
Release feed, profile screen, other UI fixes
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = "",
|
||||
|
||||
Reference in New Issue
Block a user