From ff9855aad614661bccfd84b0a7358f3320bf56d2 Mon Sep 17 00:00:00 2001 From: mstrofnone Date: Wed, 29 Jul 2026 09:01:04 +1000 Subject: [PATCH] feat(gitRepositories): add ngit-specific search on the Git Repositories screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../gitRepositories/GitRepositoriesScreen.kt | 218 +++++++++++++++++- .../gitRepositories/GitRepositoriesTopBar.kt | 92 +++++++- .../GitRepositorySearchMatcher.kt | 134 +++++++++++ amethyst/src/main/res/values/strings.xml | 4 + .../GitRepositorySearchMatcherTest.kt | 194 ++++++++++++++++ 5 files changed, 622 insertions(+), 20 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositorySearchMatcher.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositorySearchMatcherTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositoriesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositoriesScreen.kt index 3b03b5ba4c..6dcee4d39b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositoriesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositoriesScreen.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositoriesTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositoriesTopBar.kt index 4c30842a95..2d15903c9b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositoriesTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositoriesTopBar.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositorySearchMatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositorySearchMatcher.kt new file mode 100644 index 0000000000..4f40260a88 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositorySearchMatcher.kt @@ -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, + query: String, + ): List { + if (query.isBlank()) return events.toList() + return events.filter { matches(it, query) }.toList() + } + + private fun buildHaystack(event: GitRepositoryEvent): List { + val out = ArrayList(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() + 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() +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 4f0003f2ba..b6b362ed51 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3930,6 +3930,10 @@ Save Git Repositories Highlights + Filter repositories + Close filter + Filter by name, topic, host, maintainer… + No repositories in the current feed match this search. nSite: %1$s nApplet: %1$s Permissions: diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositorySearchMatcherTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositorySearchMatcherTest.kt new file mode 100644 index 0000000000..7703d9f853 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositorySearchMatcherTest.kt @@ -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 = listOf("https://github.com/vitorpamplona/amethyst.git"), + webs: List = listOf("https://amethyst.social"), + relays: List = listOf("wss://relay.ngit.dev"), + maintainers: List = listOf(maintainerHex), + hashtags: List = listOf("nostr", "android"), + euc: String? = "99614f07e4ffa99dff4143d7457be8923690bbba", + pubKey: String = ownerHex, + ): GitRepositoryEvent { + val tags = mutableListOf>() + 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) + } +}