diff --git a/js/constants.js b/js/constants.js index 86e5e7be..b0bb8663 100644 --- a/js/constants.js +++ b/js/constants.js @@ -195,7 +195,6 @@ const DEFAULT_FLAGS = Object.freeze({ // the admin instance config so a deployer flips it in one place. FEATURE_NIP71_INGEST: CONFIG_FEATURE_NIP71_INGEST !== false, FEATURE_HASHTAG_PREFERENCES: false, - FEATURE_SEARCH_FILTERS: Boolean(IS_DEV_MODE), FEATURE_TRUST_SEEDS: true, // Rollback: disable to drop baseline trust seeds without code changes. FEATURE_TRUSTED_HIDE_CONTROLS: true, FEATURE_IMPROVED_COMMENT_FETCHING: true, // Default true for comment persistence fixes @@ -218,7 +217,6 @@ const runtimeFlags = (() => { FEATURE_NIP71_MIRROR: DEFAULT_FLAGS.FEATURE_NIP71_MIRROR, FEATURE_NIP71_INGEST: DEFAULT_FLAGS.FEATURE_NIP71_INGEST, FEATURE_HASHTAG_PREFERENCES: DEFAULT_FLAGS.FEATURE_HASHTAG_PREFERENCES, - FEATURE_SEARCH_FILTERS: DEFAULT_FLAGS.FEATURE_SEARCH_FILTERS, FEATURE_TRUST_SEEDS: DEFAULT_FLAGS.FEATURE_TRUST_SEEDS, FEATURE_TRUSTED_HIDE_CONTROLS: DEFAULT_FLAGS.FEATURE_TRUSTED_HIDE_CONTROLS, FEATURE_IMPROVED_COMMENT_FETCHING: @@ -264,11 +262,6 @@ export let FEATURE_HASHTAG_PREFERENCES = coerceBoolean( DEFAULT_FLAGS.FEATURE_HASHTAG_PREFERENCES ); -export let FEATURE_SEARCH_FILTERS = coerceBoolean( - runtimeFlags.FEATURE_SEARCH_FILTERS, - DEFAULT_FLAGS.FEATURE_SEARCH_FILTERS -); - export let FEATURE_TRENDING_FEED = coerceBoolean( runtimeFlags.FEATURE_TRENDING_FEED, DEFAULT_FLAGS.FEATURE_TRENDING_FEED @@ -356,20 +349,6 @@ Object.defineProperty(runtimeFlags, "FEATURE_HASHTAG_PREFERENCES", { }, }); -Object.defineProperty(runtimeFlags, "FEATURE_SEARCH_FILTERS", { - configurable: true, - enumerable: true, - get() { - return FEATURE_SEARCH_FILTERS; - }, - set(next) { - FEATURE_SEARCH_FILTERS = coerceBoolean( - next, - DEFAULT_FLAGS.FEATURE_SEARCH_FILTERS - ); - }, -}); - Object.defineProperty(runtimeFlags, "FEATURE_TRUST_SEEDS", { configurable: true, enumerable: true, @@ -458,7 +437,6 @@ runtimeFlags.URL_FIRST_ENABLED = URL_FIRST_ENABLED; runtimeFlags.FEATURE_WATCH_HISTORY_V2 = FEATURE_WATCH_HISTORY_V2; runtimeFlags.FEATURE_PUBLISH_NIP71 = FEATURE_PUBLISH_NIP71; runtimeFlags.FEATURE_HASHTAG_PREFERENCES = FEATURE_HASHTAG_PREFERENCES; -runtimeFlags.FEATURE_SEARCH_FILTERS = FEATURE_SEARCH_FILTERS; runtimeFlags.FEATURE_TRUST_SEEDS = FEATURE_TRUST_SEEDS; runtimeFlags.FEATURE_TRUSTED_HIDE_CONTROLS = FEATURE_TRUSTED_HIDE_CONTROLS; runtimeFlags.FEATURE_IMPROVED_COMMENT_FETCHING = @@ -555,16 +533,10 @@ export function setHashtagPreferencesEnabled(next) { return FEATURE_HASHTAG_PREFERENCES; } -export function setSearchFiltersEnabled(next) { - runtimeFlags.FEATURE_SEARCH_FILTERS = next; - return FEATURE_SEARCH_FILTERS; -} - export function resetRuntimeFlags() { setUrlFirstEnabled(DEFAULT_FLAGS.URL_FIRST_ENABLED); setWatchHistoryV2Enabled(DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2); setHashtagPreferencesEnabled(DEFAULT_FLAGS.FEATURE_HASHTAG_PREFERENCES); - setSearchFiltersEnabled(DEFAULT_FLAGS.FEATURE_SEARCH_FILTERS); setTrustSeedsEnabled(DEFAULT_FLAGS.FEATURE_TRUST_SEEDS); setTrustedHideControlsEnabled(DEFAULT_FLAGS.FEATURE_TRUSTED_HIDE_CONTROLS); setImprovedCommentFetchingEnabled( diff --git a/js/search/searchFilterMatchers.js b/js/search/searchFilterMatchers.js index 13628efe..6013760b 100644 --- a/js/search/searchFilterMatchers.js +++ b/js/search/searchFilterMatchers.js @@ -56,7 +56,11 @@ export function buildVideoSearchFilterMatcher(filters = {}, options = {}) { if (hasUrl && !video.url) return false; if (Number.isFinite(minDuration) || Number.isFinite(maxDuration)) { - const duration = Number(video.nip71?.duration); + // Both video types carry duration differently: bitvid's kind-30078 puts + // it top-level (captured at upload); ingested NIP-71 (21/22/34235/34236) + // nests it in nip71 metadata. Future live (30311) / shorts use the same + // shapes, so check both. + const duration = Number(video.duration) || Number(video.nip71?.duration); if (!Number.isFinite(duration)) return false; if (Number.isFinite(minDuration) && duration < minDuration) return false; if (Number.isFinite(maxDuration) && duration > maxDuration) return false; @@ -78,3 +82,37 @@ export function buildVideoSearchFilterMatcher(filters = {}, options = {}) { return true; }; } + +// Sort search results per the modal's Sort By options. Pure + injectable +// (getViews) so it is unit-testable. "recent" (and the "relevance" default) +// keep newest-first; "views" ranks by the shared view-count cache; "trending" +// weights views by recency (views / age-in-days^1.5, same spirit as the +// Trending tab); "longest" ranks by duration. +export function sortSearchResults(videos, sort, { getViews, now = Date.now() } = {}) { + const list = Array.isArray(videos) ? [...videos] : []; + const views = (video) => { + if (typeof getViews !== "function") return 0; + const value = getViews(video); + return Number.isFinite(value) ? value : 0; + }; + const createdAt = (video) => Number(video?.created_at) || 0; + const duration = (video) => + Number(video?.duration) || Number(video?.nip71?.duration) || 0; + + switch (sort) { + case "views": + return list.sort((a, b) => views(b) - views(a) || createdAt(b) - createdAt(a)); + case "trending": { + const score = (video) => { + const ageDays = Math.max((now / 1000 - createdAt(video)) / 86400, 0.25); + return views(video) / Math.pow(ageDays, 1.5); + }; + return list.sort((a, b) => score(b) - score(a) || createdAt(b) - createdAt(a)); + } + case "longest": + return list.sort((a, b) => duration(b) - duration(a) || createdAt(b) - createdAt(a)); + case "recent": + default: + return list.sort((a, b) => createdAt(b) - createdAt(a)); + } +} diff --git a/js/searchView.js b/js/searchView.js index 4cac8b7a..2748ab4c 100644 --- a/js/searchView.js +++ b/js/searchView.js @@ -661,10 +661,14 @@ async function performVideoSearch(query, token, filters = {}) { matches = video.tags.some(t => Array.isArray(t) && t[0] === "t" && t[1].toLowerCase() === lowerTerm); } } else { - // Check title/description + // Check title/description, honoring the textScope filter + // (all | title | description). + const scope = filters?.textScope || "all"; const title = video.title ? video.title.toLowerCase() : ""; const desc = video.description ? video.description.toLowerCase() : ""; - if (title.includes(lowerTerm) || desc.includes(lowerTerm)) { + const titleHit = scope !== "description" && title.includes(lowerTerm); + const descHit = scope !== "title" && desc.includes(lowerTerm); + if (titleHit || descHit) { matches = true; } } @@ -681,7 +685,8 @@ async function performVideoSearch(query, token, filters = {}) { // 2. Relay Search let relayVideos = []; - const relays = nostrClient.relays || []; + // relay: filter scopes the relay-side search to that single relay. + const relays = filters?.relay ? [filters.relay] : nostrClient.relays || []; if (relays.length > 0 && nostrClient.pool) { const filter = { @@ -733,6 +738,22 @@ async function performVideoSearch(query, token, filters = {}) { if (!unique.has(v.id)) unique.set(v.id, v); } - // Sort by creation date (newest first) - return Array.from(unique.values()).sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); + // Sort per the active filter (recent/views/trending/longest); "recent" is + // the default. Views come from the shared deduped view-count cache. + const { sortSearchResults } = await import("./search/searchFilterMatchers.js"); + const { getVideoViewCountSnapshot } = await import("./viewCounter.js"); + return sortSearchResults(Array.from(unique.values()), filters?.sort, { + getViews: (video) => { + try { + const snapshot = getVideoViewCountSnapshot([ + "a", + 30078, + video.videoRootId || video.id, + ]); + return Number(snapshot?.total) || 0; + } catch (error) { + return 0; + } + }, + }); } diff --git a/tests/search-filters.test.mjs b/tests/search-filters.test.mjs new file mode 100644 index 00000000..d11cb34d --- /dev/null +++ b/tests/search-filters.test.mjs @@ -0,0 +1,118 @@ +// Search pipeline coverage (was zero): the token parser, the video filter +// matcher, and the result sorter — the pure core of search. +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-search-core +// given: "query strings / videos with varying metadata" +// when: "parseFilterQuery / buildVideoSearchFilterMatcher / sortSearchResults run" +// then: "tokens parse into filters; matcher enforces them; sort honors the selected mode" +// observable_outcomes: +// - "tag:/has:/duration:< tokens land in filters + free text preserved" +// - "matcher enforces tags, date range, has:magnet, duration bounds" +// - "sort: views ranks by injected view counts; longest by duration; recent by created_at" +// determinism_controls: +// - "pure functions; injected getViews + fixed now" +// anti_cheat_rationale: +// prevents: ["hard-coded return value", "over-mocking internal logic"] +// relaxation: +// did_relax_any_assertion: false + +import "./test-helpers/setup-localstorage.mjs"; +import test from "node:test"; +import { strict as assert } from "node:assert"; +import { parseFilterQuery } from "../js/search/searchFilters.js"; +import { + buildVideoSearchFilterMatcher, + sortSearchResults, +} from "../js/search/searchFilterMatchers.js"; + +test("parser: tokens land in filters, free text preserved", () => { + const { text, filters, errors } = parseFilterQuery( + "cat videos tag:nostr has:magnet duration:<120", + ); + assert.equal(errors.length, 0); + assert.equal(text, "cat videos"); + assert.deepEqual(filters.tags, ["nostr"]); + assert.equal(filters.hasMagnet, true); + assert.equal(filters.duration.maxSeconds, 120); +}); + +test("matcher enforces tags, date range, has:magnet, duration", () => { + const video = { + pubkey: "a".repeat(64), + created_at: 1_700_000_000, + tags: [["t", "nostr"]], + magnet: "magnet:?xt=urn:btih:abc", + nip71: { duration: 90 }, // ingested NIP-71 shape (bitvid kind uses top-level) + }; + const pass = buildVideoSearchFilterMatcher({ + tags: ["nostr"], + hasMagnet: true, + duration: { minSeconds: 60, maxSeconds: 120 }, + dateRange: { after: 1_699_999_999, before: 1_700_000_001 }, + }); + assert.equal(pass(video), true); + assert.equal(pass({ ...video, tags: [] }), false, "missing tag rejected"); + assert.equal(pass({ ...video, magnet: "" }), false, "has:magnet rejected"); + assert.equal( + pass({ ...video, nip71: { duration: 30 } }), + false, + "too short rejected", + ); + // bitvid's own kind-30078 videos carry duration TOP-LEVEL — both video types + // must satisfy duration filters (spec: two video kinds, more coming for + // live/shorts per the dev plans). + assert.equal( + pass({ ...video, nip71: undefined, duration: 90 }), + true, + "top-level duration (bitvid kind) matches too", + ); + assert.equal( + pass({ ...video, created_at: 1_690_000_000 }), + false, + "outside date range rejected", + ); +}); + +test("sortSearchResults honors views / longest / recent (and defaults to recent)", () => { + const videos = [ + { id: "old-popular", created_at: 100, duration: 10 }, + { id: "new-quiet", created_at: 300, duration: 50 }, + { id: "mid-long", created_at: 200, duration: 500 }, + ]; + const viewsById = { "old-popular": 900, "new-quiet": 5, "mid-long": 40 }; + const getViews = (v) => viewsById[v.id]; + + assert.deepEqual( + sortSearchResults(videos, "views", { getViews }).map((v) => v.id), + ["old-popular", "mid-long", "new-quiet"], + ); + assert.deepEqual( + sortSearchResults(videos, "longest", { getViews }).map((v) => v.id), + ["mid-long", "new-quiet", "old-popular"], + ); + assert.deepEqual( + sortSearchResults(videos, "recent", { getViews }).map((v) => v.id), + ["new-quiet", "mid-long", "old-popular"], + ); + // Unknown/default -> recent; input array is not mutated. + const copy = [...videos]; + sortSearchResults(videos, undefined, { getViews }); + assert.deepEqual(videos, copy); +}); + +test("sort: trending weights views by recency", () => { + const now = 1_700_000_000_000; // ms + const daySec = 86400; + const videos = [ + { id: "old-hit", created_at: now / 1000 - 30 * daySec }, // 900 views, 30d old + { id: "fresh-riser", created_at: now / 1000 - 1 * daySec }, // 60 views, 1d old + ]; + const getViews = (v) => (v.id === "old-hit" ? 900 : 60); + const ranked = sortSearchResults(videos, "trending", { getViews, now }).map( + (v) => v.id, + ); + assert.deepEqual(ranked, ["fresh-riser", "old-hit"]); +});