mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 08:47:33 +00:00
feat(gitRepositories): add ngit-specific search on the Git Repositories screen
Adds a client-side filter for the Git Repositories page that only searches fields relevant to ngit repository announcements (kind:30617 NIP-34): repo name, `d` identifier, description, hashtags/topics, clone URLs, web URLs, maintainer relays, maintainer/author pubkeys (accepting both hex and `npub…` bech32 in the query), and the earliest-unique-commit hash. Before this change, the only search affordance on the screen was the generic Nostr search icon that navigated away to `Route.Search`, which matches people, notes, hashtags, and channels — none of which are ngit repositories. Users who wanted to find a repo they'd already discovered had to scroll through the full follow-list-scoped feed. UX: - A filter icon in the top bar toggles an inline `OutlinedTextField` directly above the feed. First-appearance focus opens the keyboard without a second tap. - The general search icon is preserved beside the filter icon so outbound searches still work. - While filtering, results render with the same `NoteCompose` cells the feed uses so every affordance (bookmark, open, share) still works. - Filtered rendering uses a scoped `LazyListState` because the item-key set of the filtered list is not stable against the feed's cached scroll offset; sharing them would jump the user to an unrelated repo. - Closing the filter icon clears the query, restoring the full feed in one tap. Filter semantics: - Whitespace-separated terms are ANDed against each repo (`amethyst nostr` matches only repos that carry both terms in some indexed field). - Case-insensitive substring match on each indexed field. - `npub1…` queries are decoded to hex before matching, so a maintainer can be found by either encoding. Tests: `GitRepositorySearchMatcherTest` (17 hermetic cases) pins every indexed field, plus the "empty query returns nothing / filter blank returns everything" contract that the caller relies on to skip the filter path. Build check: `./gradlew :amethyst:compileFdroidDebugKotlin :amethyst:testFdroidDebugUnitTest --tests 'com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.GitRepositorySearchMatcherTest' :amethyst:spotlessCheck` all green.
This commit is contained in:
committed by
Vitor Pamplona
parent
372964194d
commit
ff9855aad6
+209
-9
@@ -20,11 +20,35 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
|
||||
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
|
||||
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
|
||||
@@ -34,8 +58,17 @@ import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.ClearTextIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.SearchIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.GitRepositoriesFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
|
||||
|
||||
@Composable
|
||||
fun GitRepositoriesScreen(
|
||||
@@ -59,10 +92,31 @@ fun GitRepositoriesScreen(
|
||||
WatchAccountForGitRepositoriesScreen(gitRepositoriesFeedContentState = gitRepositoriesFeedContentState, accountViewModel = accountViewModel)
|
||||
GitRepositoriesFilterAssemblerSubscription(accountViewModel)
|
||||
|
||||
// Search UI state is remembered across configuration changes so the
|
||||
// user doesn't lose their query when rotating; scoped to this screen,
|
||||
// not persisted to disk (unlike the follow-list filter above).
|
||||
var isSearchOpen by rememberSaveable { mutableStateOf(false) }
|
||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
DisappearingScaffold(
|
||||
isInvertedLayout = false,
|
||||
topBar = {
|
||||
GitRepositoriesTopBar(accountViewModel, nav)
|
||||
GitRepositoriesTopBar(
|
||||
isSearchOpen = isSearchOpen,
|
||||
onToggleSearch = {
|
||||
// Closing collapses the field AND clears the query so
|
||||
// the feed is fully restored — the icon acts as a
|
||||
// one-tap "reset" once the user has narrowed the view.
|
||||
if (isSearchOpen) {
|
||||
searchQuery = ""
|
||||
isSearchOpen = false
|
||||
} else {
|
||||
isSearchOpen = true
|
||||
}
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
AppBottomBar(Route.GitRepositories, nav, accountViewModel) { route ->
|
||||
@@ -75,20 +129,166 @@ fun GitRepositoriesScreen(
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
RefresheableBox(gitRepositoriesFeedContentState, true) {
|
||||
SaveableFeedContentState(gitRepositoriesFeedContentState, scrollStateKey = ScrollStateKeys.GIT_REPOSITORIES_SCREEN) { listState ->
|
||||
RenderFeedContentState(
|
||||
feedContentState = gitRepositoriesFeedContentState,
|
||||
accountViewModel = accountViewModel,
|
||||
listState = listState,
|
||||
nav = nav,
|
||||
routeForLastRead = "GitRepositoriesFeed",
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
if (isSearchOpen) {
|
||||
GitRepositorySearchField(
|
||||
query = searchQuery,
|
||||
onQueryChange = { searchQuery = it },
|
||||
onClearQuery = { searchQuery = "" },
|
||||
)
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
RefresheableBox(gitRepositoriesFeedContentState, true) {
|
||||
SaveableFeedContentState(gitRepositoriesFeedContentState, scrollStateKey = ScrollStateKeys.GIT_REPOSITORIES_SCREEN) { listState ->
|
||||
val query = searchQuery
|
||||
if (query.isBlank()) {
|
||||
RenderFeedContentState(
|
||||
feedContentState = gitRepositoriesFeedContentState,
|
||||
accountViewModel = accountViewModel,
|
||||
listState = listState,
|
||||
nav = nav,
|
||||
routeForLastRead = "GitRepositoriesFeed",
|
||||
)
|
||||
} else {
|
||||
// When the filter is active we can't reuse the shared
|
||||
// scroll state because the filtered list has a different
|
||||
// set of item keys — using the same LazyListState would
|
||||
// make Compose try to restore an index that no longer
|
||||
// exists and jump the user to an unrelated repo. We
|
||||
// scope a fresh, per-query LazyListState so scrolling
|
||||
// stays inside the filtered view.
|
||||
RenderFilteredFeed(
|
||||
feedContentState = gitRepositoriesFeedContentState,
|
||||
query = query,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline text field that drives the client-side ngit-repository search. Sits
|
||||
* directly under the top bar and above the feed so the user can see the
|
||||
* result of every keystroke narrow the list beneath it.
|
||||
*/
|
||||
@Composable
|
||||
private fun GitRepositorySearchField(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
onClearQuery: () -> Unit,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
// Focus on first appearance so the keyboard opens without a second
|
||||
// tap. Subsequent recompositions inside the same session don't re-
|
||||
// request focus, which would fight with the user pressing "back to
|
||||
// the feed" via the field's clear-text icon.
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp)) {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringRes(R.string.git_repositories_search_placeholder),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
leadingIcon = { SearchIcon(modifier = Size20Modifier, MaterialTheme.colorScheme.placeholderText) },
|
||||
trailingIcon = {
|
||||
if (query.isNotEmpty()) {
|
||||
IconButton(onClick = onClearQuery) {
|
||||
ClearTextIcon()
|
||||
}
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the ngit repositories the user is already subscribed to, filtered
|
||||
* by [query]. Loading and error states are delegated to the shared
|
||||
* [RenderFeedContentState] via the appropriate branches; the loaded branch
|
||||
* is intercepted so we can filter the notes without touching the shared
|
||||
* feed model (which other screens also observe).
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun RenderFilteredFeed(
|
||||
feedContentState: FeedContentState,
|
||||
query: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val filteredListState = rememberLazyListState()
|
||||
|
||||
RenderFeedContentState(
|
||||
feedContentState = feedContentState,
|
||||
accountViewModel = accountViewModel,
|
||||
listState = filteredListState,
|
||||
nav = nav,
|
||||
routeForLastRead = "GitRepositoriesFeed",
|
||||
onLoaded = { loaded ->
|
||||
val loadedItems by loaded.feed.collectAsStateWithLifecycle()
|
||||
|
||||
val filtered =
|
||||
remember(loadedItems, query) {
|
||||
loadedItems.list.filter { note ->
|
||||
val event = note.event as? GitRepositoryEvent ?: return@filter false
|
||||
GitRepositorySearchMatcher.matches(event, query)
|
||||
}
|
||||
}
|
||||
|
||||
if (filtered.isEmpty()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.git_repositories_search_no_results),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = rememberFeedContentPadding(FeedPadding),
|
||||
state = filteredListState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
itemsIndexed(
|
||||
filtered,
|
||||
key = { _, item -> item.idHex },
|
||||
contentType = { _, item -> item.event?.kind ?: -1 },
|
||||
) { _, item ->
|
||||
Row(Modifier.fillMaxWidth().animateItem()) {
|
||||
NoteCompose(
|
||||
item,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
routeForLastRead = "GitRepositoriesFeed",
|
||||
isBoostedNote = false,
|
||||
isHiddenFeed = loadedItems.showHidden,
|
||||
quotesLeft = 3,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WatchAccountForGitRepositoriesScreen(
|
||||
gitRepositoriesFeedContentState: FeedContentState,
|
||||
|
||||
+81
-11
@@ -20,35 +20,105 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarNavigationIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.SearchIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
|
||||
import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
|
||||
/**
|
||||
* Top bar for the ngit repositories discovery screen.
|
||||
*
|
||||
* Two search affordances live side-by-side in the actions row:
|
||||
*
|
||||
* 1. A **repository filter** (magnifier-with-a-plus icon) that toggles
|
||||
* an inline text field over the loaded feed. This is the ngit-specific
|
||||
* search — it matches the fields NIP-34 announcements carry: name,
|
||||
* identifier, description, hashtags, clone/web/relay URLs, and
|
||||
* maintainer pubkeys. It filters what the user is already looking
|
||||
* at without touching relays.
|
||||
*
|
||||
* 2. The **generic Nostr search** (plain magnifier) that navigates to
|
||||
* the global [Route.Search] screen, matching the affordance on
|
||||
* every other top-level screen.
|
||||
*
|
||||
* Splitting them this way makes it obvious which magnifier does what: the
|
||||
* inline one narrows the current list, the outbound one opens the fleet-
|
||||
* wide search that also queries people, notes, hashtags, etc.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GitRepositoriesTopBar(
|
||||
isSearchOpen: Boolean,
|
||||
onToggleSearch: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
UserDrawerSearchTopBar(accountViewModel, nav) {
|
||||
val list by accountViewModel.account.settings.defaultGitRepositoriesFollowList
|
||||
.collectAsStateWithLifecycle()
|
||||
ShorterTopAppBar(
|
||||
title = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
val list by accountViewModel.account.settings.defaultGitRepositoriesFollowList
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
GitRepositoriesTopNavFilterBar(
|
||||
followListsModel = accountViewModel.feedStates.feedListOptions,
|
||||
listName = list,
|
||||
accountViewModel = accountViewModel,
|
||||
onChange = accountViewModel.account.settings::changeDefaultGitRepositoriesFollowList,
|
||||
)
|
||||
}
|
||||
GitRepositoriesTopNavFilterBar(
|
||||
followListsModel = accountViewModel.feedStates.feedListOptions,
|
||||
listName = list,
|
||||
accountViewModel = accountViewModel,
|
||||
onChange = accountViewModel.account.settings::changeDefaultGitRepositoriesFollowList,
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = { TopBarNavigationIcon(accountViewModel, nav) },
|
||||
actions = {
|
||||
IconButton(onClick = onToggleSearch) {
|
||||
Icon(
|
||||
symbol =
|
||||
if (isSearchOpen) {
|
||||
MaterialSymbols.Close
|
||||
} else {
|
||||
MaterialSymbols.FilterAlt
|
||||
},
|
||||
contentDescription =
|
||||
stringRes(
|
||||
if (isSearchOpen) {
|
||||
R.string.git_repositories_search_close
|
||||
} else {
|
||||
R.string.git_repositories_search_open
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { nav.nav(Route.Search) }) {
|
||||
SearchIcon(modifier = Size22Modifier, MaterialTheme.colorScheme.placeholderText)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories
|
||||
|
||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
|
||||
|
||||
/**
|
||||
* Local, in-memory matcher for the Git Repositories screen search box.
|
||||
*
|
||||
* The screen already loads the full set of ngit repository announcements
|
||||
* (`kind:30617` `GitRepositoryEvent`) the user has subscribed to via their
|
||||
* follow lists / follow set, so a client-side filter avoids issuing an
|
||||
* extra NIP-50 relay query for the common "I know its name/topic/host"
|
||||
* lookup. It also matches on fields the generic NIP-50 search would
|
||||
* ignore — clone/web URLs, maintainer npubs, the ngit `d` identifier
|
||||
* — which are exactly what someone browsing repos on gitworkshop /
|
||||
* ngit tends to remember.
|
||||
*
|
||||
* The query is split on whitespace so `"amethyst nostr"` requires each
|
||||
* term to appear in at least one indexed field of the same repository.
|
||||
* Every term match is case-insensitive.
|
||||
*
|
||||
* Indexed fields:
|
||||
* - repo name (`name` tag)
|
||||
* - repo identifier (`d` tag; what appears in the ngit URL path)
|
||||
* - description
|
||||
* - hashtags/topics (`t` tags)
|
||||
* - clone URLs (`clone` tag values)
|
||||
* - web URLs (`web` tag values)
|
||||
* - relay URLs the maintainers listen on (`relays` tag values)
|
||||
* - maintainer pubkeys (both hex and NIP-19 `npub…` form)
|
||||
* - repo author pubkey (hex and npub)
|
||||
* - earliest-unique-commit hash (`r … euc`) — lets you paste a commit
|
||||
* hash from a nostr:naddr and land on the repo
|
||||
*/
|
||||
object GitRepositorySearchMatcher {
|
||||
/**
|
||||
* @return `true` when [event] matches every whitespace-separated term
|
||||
* in [query]. An empty query matches nothing (callers should skip the
|
||||
* filter path in that case).
|
||||
*/
|
||||
fun matches(
|
||||
event: GitRepositoryEvent,
|
||||
query: String,
|
||||
): Boolean {
|
||||
val terms = query.trim().split(WHITESPACE).filter { it.isNotEmpty() }
|
||||
if (terms.isEmpty()) return false
|
||||
|
||||
val haystack = buildHaystack(event)
|
||||
return terms.all { term ->
|
||||
val needle = term.lowercase()
|
||||
// Support "npub1…" queries by resolving them to hex; the hex
|
||||
// form is already in the haystack via authorNpubs / dTag /
|
||||
// maintainers.
|
||||
val hexFromBech32 = tryDecodeNpubToHex(needle)
|
||||
haystack.any { field -> field.contains(needle) } ||
|
||||
(hexFromBech32 != null && haystack.any { field -> field.contains(hexFromBech32) })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as [matches] but returns the list of unique repositories
|
||||
* ordered by the caller-provided iteration order. Duplicate `d`
|
||||
* tags collapse to the newest event, because a maintainer publishing
|
||||
* two revisions of `amethyst` is still one repo.
|
||||
*/
|
||||
fun filter(
|
||||
events: Sequence<GitRepositoryEvent>,
|
||||
query: String,
|
||||
): List<GitRepositoryEvent> {
|
||||
if (query.isBlank()) return events.toList()
|
||||
return events.filter { matches(it, query) }.toList()
|
||||
}
|
||||
|
||||
private fun buildHaystack(event: GitRepositoryEvent): List<String> {
|
||||
val out = ArrayList<String>(16)
|
||||
event.name()?.lowercase()?.let(out::add)
|
||||
event
|
||||
.dTag()
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.lowercase()
|
||||
?.let(out::add)
|
||||
event.description()?.lowercase()?.let(out::add)
|
||||
event.hashtags().forEach { out.add(it.lowercase()) }
|
||||
event.clones().forEach { out.add(it.lowercase()) }
|
||||
event.webs().forEach { out.add(it.lowercase()) }
|
||||
event.relays().forEach { out.add(it.lowercase()) }
|
||||
// Maintainers as hex + npub. Author is an implicit maintainer per
|
||||
// NIP-34, so include it in both forms too.
|
||||
val authors = HashSet<String>()
|
||||
authors.add(event.pubKey)
|
||||
authors.addAll(event.maintainers())
|
||||
authors.forEach { hex ->
|
||||
out.add(hex.lowercase())
|
||||
hexToNpub(hex)?.let { out.add(it.lowercase()) }
|
||||
}
|
||||
event.earliestUniqueCommit()?.lowercase()?.let(out::add)
|
||||
return out
|
||||
}
|
||||
|
||||
private val WHITESPACE = Regex("\\s+")
|
||||
|
||||
private fun tryDecodeNpubToHex(candidate: String): String? {
|
||||
if (!candidate.startsWith("npub1")) return null
|
||||
return runCatching {
|
||||
when (val parsed = Nip19Parser.uriToRoute(candidate)?.entity) {
|
||||
is NPub -> parsed.hex
|
||||
else -> null
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun hexToNpub(hex: String): String? = runCatching { NPub.create(hex) }.getOrNull()
|
||||
}
|
||||
@@ -3930,6 +3930,10 @@
|
||||
<string name="git_repo_settings_save">Save</string>
|
||||
<string name="git_repositories">Git Repositories</string>
|
||||
<string name="highlights">Highlights</string>
|
||||
<string name="git_repositories_search_open">Filter repositories</string>
|
||||
<string name="git_repositories_search_close">Close filter</string>
|
||||
<string name="git_repositories_search_placeholder">Filter by name, topic, host, maintainer…</string>
|
||||
<string name="git_repositories_search_no_results">No repositories in the current feed match this search.</string>
|
||||
<string name="nsite_title">nSite: %1$s</string>
|
||||
<string name="napplet_card_title">nApplet: %1$s</string>
|
||||
<string name="napplet_card_permissions">Permissions:</string>
|
||||
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories
|
||||
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins the ngit-repository-relevant search matcher used by
|
||||
* `GitRepositoriesScreen`. Every field the matcher promises to index is
|
||||
* exercised once here so a future refactor that drops one (e.g. relays)
|
||||
* shows up as a red test.
|
||||
*/
|
||||
class GitRepositorySearchMatcherTest {
|
||||
private val ownerHex = "aa".repeat(32)
|
||||
private val maintainerHex = "bb".repeat(32)
|
||||
private val ownerNpub = NPub.create(ownerHex)
|
||||
private val maintainerNpub = NPub.create(maintainerHex)
|
||||
|
||||
private fun repo(
|
||||
name: String = "amethyst",
|
||||
dTag: String = "amethyst",
|
||||
description: String? = "A Nostr client for Android",
|
||||
clones: List<String> = listOf("https://github.com/vitorpamplona/amethyst.git"),
|
||||
webs: List<String> = listOf("https://amethyst.social"),
|
||||
relays: List<String> = listOf("wss://relay.ngit.dev"),
|
||||
maintainers: List<String> = listOf(maintainerHex),
|
||||
hashtags: List<String> = listOf("nostr", "android"),
|
||||
euc: String? = "99614f07e4ffa99dff4143d7457be8923690bbba",
|
||||
pubKey: String = ownerHex,
|
||||
): GitRepositoryEvent {
|
||||
val tags = mutableListOf<Array<String>>()
|
||||
tags += arrayOf("d", dTag)
|
||||
tags += arrayOf("name", name)
|
||||
description?.let { tags += arrayOf("description", it) }
|
||||
clones.forEach { tags += arrayOf("clone", it) }
|
||||
webs.forEach { tags += arrayOf("web", it) }
|
||||
if (relays.isNotEmpty()) tags += arrayOf("relays", *relays.toTypedArray())
|
||||
if (maintainers.isNotEmpty()) tags += arrayOf("maintainers", *maintainers.toTypedArray())
|
||||
hashtags.forEach { tags += arrayOf("t", it) }
|
||||
euc?.let { tags += arrayOf("r", it, "euc") }
|
||||
return GitRepositoryEvent(
|
||||
id = "00".repeat(32),
|
||||
pubKey = pubKey,
|
||||
createdAt = 0L,
|
||||
tags = tags.toTypedArray(),
|
||||
content = "",
|
||||
sig = "00",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyQueryMatchesNothing() {
|
||||
// Callers must skip the filter path themselves — the matcher is
|
||||
// conservative and refuses to accept an empty term list.
|
||||
assertFalse(GitRepositorySearchMatcher.matches(repo(), ""))
|
||||
assertFalse(GitRepositorySearchMatcher.matches(repo(), " "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchesRepoNameCaseInsensitive() {
|
||||
assertTrue(GitRepositorySearchMatcher.matches(repo(name = "Amethyst"), "amethyst"))
|
||||
assertTrue(GitRepositorySearchMatcher.matches(repo(name = "Amethyst"), "AMET"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchesRepoIdentifierDTag() {
|
||||
assertTrue(GitRepositorySearchMatcher.matches(repo(dTag = "ngit-cli"), "ngit-cli"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchesDescription() {
|
||||
assertTrue(
|
||||
GitRepositorySearchMatcher.matches(
|
||||
repo(description = "A private Nostr messenger"),
|
||||
"messenger",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchesHashtag() {
|
||||
assertTrue(GitRepositorySearchMatcher.matches(repo(hashtags = listOf("kotlin", "mobile")), "kotlin"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchesCloneUrl() {
|
||||
assertTrue(
|
||||
GitRepositorySearchMatcher.matches(
|
||||
repo(clones = listOf("https://relay.ngit.dev/npub1abc/foo.git")),
|
||||
"relay.ngit.dev",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchesWebUrl() {
|
||||
assertTrue(GitRepositorySearchMatcher.matches(repo(webs = listOf("https://gitworkshop.dev/x")), "gitworkshop"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchesRelayHost() {
|
||||
assertTrue(
|
||||
GitRepositorySearchMatcher.matches(
|
||||
repo(relays = listOf("wss://relay.damus.io")),
|
||||
"damus.io",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchesMaintainerHex() {
|
||||
assertTrue(GitRepositorySearchMatcher.matches(repo(), maintainerHex))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchesMaintainerNpub() {
|
||||
// The `bb…` npub is a valid bech32 pubkey; supplying it as a
|
||||
// query must resolve to the same hex the tag carries.
|
||||
assertTrue(GitRepositorySearchMatcher.matches(repo(), maintainerNpub))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchesAuthorHex() {
|
||||
assertTrue(GitRepositorySearchMatcher.matches(repo(), ownerHex))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchesAuthorNpub() {
|
||||
assertTrue(GitRepositorySearchMatcher.matches(repo(), ownerNpub))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchesEarliestUniqueCommit() {
|
||||
// Full euc must match; a prefix that lives inside it should too.
|
||||
assertTrue(GitRepositorySearchMatcher.matches(repo(euc = "99614f07e4ff"), "99614f07"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multipleTermsMustAllMatch() {
|
||||
val target = repo(name = "amethyst", hashtags = listOf("nostr", "android"))
|
||||
assertTrue(GitRepositorySearchMatcher.matches(target, "amethyst android"))
|
||||
// "kotlin" isn't in this repo's fields, so the AND fails.
|
||||
assertFalse(GitRepositorySearchMatcher.matches(target, "amethyst kotlin"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidNpubTreatedAsRawText() {
|
||||
// "npub1notreallybech32" is not a decodable npub; the matcher
|
||||
// should still let it match as a raw substring of e.g. the
|
||||
// description, without throwing.
|
||||
val target = repo(description = "npub1notreallybech32 is a placeholder")
|
||||
assertTrue(GitRepositorySearchMatcher.matches(target, "npub1notreallybech32"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun filterReturnsAllMatches() {
|
||||
val a = repo(name = "amethyst")
|
||||
val b = repo(name = "ngit-cli", dTag = "ngit-cli", hashtags = listOf("rust", "git"))
|
||||
val c = repo(name = "shakespeare", dTag = "shakespeare")
|
||||
val hits = GitRepositorySearchMatcher.filter(sequenceOf(a, b, c), "rust")
|
||||
assertEquals(listOf(b), hits)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun filterBlankQueryReturnsEverything() {
|
||||
val a = repo(name = "amethyst")
|
||||
val b = repo(name = "ngit-cli")
|
||||
val hits = GitRepositorySearchMatcher.filter(sequenceOf(a, b), " ")
|
||||
assertEquals(listOf(a, b), hits)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user