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:
mstrofnone
2026-08-05 18:08:45 -04:00
committed by Vitor Pamplona
parent 372964194d
commit ff9855aad6
5 changed files with 622 additions and 20 deletions
@@ -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,
@@ -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
@@ -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()
}
+4
View File
@@ -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>
@@ -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)
}
}