mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Merge branch 'unstable' into jules-fix-sidebar-and-baseline-297374874061158968
This commit is contained in:
6 files changed
+581
-59
No files matched your search
+14
@@ -215,6 +215,13 @@
|
||||
></span>
|
||||
</button>
|
||||
</form>
|
||||
<p
|
||||
id="headerSearchMessage"
|
||||
class="mt-1 text-xs text-warning hidden"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-search-parse-message
|
||||
></p>
|
||||
</div>
|
||||
|
||||
<!-- Buttons on the far right -->
|
||||
@@ -413,6 +420,13 @@
|
||||
></span>
|
||||
</button>
|
||||
</form>
|
||||
<p
|
||||
id="mobileSearchMessage"
|
||||
class="mt-2 text-xs text-warning hidden"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-search-parse-message
|
||||
></p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
+4
-1
@@ -18,7 +18,10 @@ import {
|
||||
} from "./themeController.js";
|
||||
import { setHashView } from "./hashView.js";
|
||||
import { devLogger, userLogger } from "./utils/logger.js";
|
||||
import { parseFilterQuery } from "./search/searchFilters.js";
|
||||
import {
|
||||
parseFilterQuery,
|
||||
serializeFiltersToQuery,
|
||||
} from "./search/searchFilters.js";
|
||||
import {
|
||||
buildSearchHashFromState,
|
||||
getSearchFilterState,
|
||||
|
||||
@@ -24,6 +24,8 @@ const cloneFilters = (filters = DEFAULT_FILTERS) => ({
|
||||
hasMagnet:
|
||||
typeof filters?.hasMagnet === "boolean" ? filters.hasMagnet : null,
|
||||
hasUrl: typeof filters?.hasUrl === "boolean" ? filters.hasUrl : null,
|
||||
hasTranscript:
|
||||
typeof filters?.hasTranscript === "boolean" ? filters.hasTranscript : null,
|
||||
nsfw: filters?.nsfw ?? DEFAULT_FILTERS.nsfw,
|
||||
relay: filters?.relay ?? DEFAULT_FILTERS.relay,
|
||||
kind:
|
||||
|
||||
+109
-29
@@ -4,6 +4,17 @@ const DATE_FORMAT_OPTIONS = {
|
||||
day: "2-digit",
|
||||
};
|
||||
|
||||
export const SORT_OPTIONS = [
|
||||
{ value: "relevance", label: "Relevance" },
|
||||
{ value: "newest", label: "Newest" },
|
||||
{ value: "oldest", label: "Oldest" },
|
||||
{ value: "views", label: "Most Views" },
|
||||
{ value: "zaps", label: "Most Zaps", experimental: true },
|
||||
{ value: "reactions", label: "Most Reactions/Comments", experimental: true },
|
||||
];
|
||||
|
||||
const SORT_VALUES = new Set(SORT_OPTIONS.map((option) => option.value));
|
||||
|
||||
export const DEFAULT_FILTERS = {
|
||||
dateRange: {
|
||||
after: null,
|
||||
@@ -19,6 +30,7 @@ export const DEFAULT_FILTERS = {
|
||||
},
|
||||
hasMagnet: null,
|
||||
hasUrl: null,
|
||||
hasTranscript: null,
|
||||
nsfw: "any",
|
||||
relay: null,
|
||||
kind: null,
|
||||
@@ -35,30 +47,51 @@ const cloneDefaultFilters = () => ({
|
||||
duration: { ...DEFAULT_FILTERS.duration },
|
||||
hasMagnet: DEFAULT_FILTERS.hasMagnet,
|
||||
hasUrl: DEFAULT_FILTERS.hasUrl,
|
||||
hasTranscript: DEFAULT_FILTERS.hasTranscript,
|
||||
nsfw: DEFAULT_FILTERS.nsfw,
|
||||
relay: DEFAULT_FILTERS.relay,
|
||||
kind: DEFAULT_FILTERS.kind,
|
||||
});
|
||||
|
||||
const normalizeWhitespace = (input) =>
|
||||
input.replace(/\s+/g, " ").trim();
|
||||
|
||||
const tokenizeQuery = (input) => {
|
||||
const tokens = [];
|
||||
let current = "";
|
||||
let currentIsPhrase = false;
|
||||
let inQuotes = false;
|
||||
let sawUnclosedQuote = false;
|
||||
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
const char = input[i];
|
||||
if (char === '"' && input[i - 1] !== "\\") {
|
||||
inQuotes = !inQuotes;
|
||||
|
||||
if (char === "\\" && input[i + 1] === '"') {
|
||||
current += '"';
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
if (inQuotes) {
|
||||
tokens.push({ value: current, isPhrase: true });
|
||||
current = "";
|
||||
currentIsPhrase = false;
|
||||
inQuotes = false;
|
||||
} else {
|
||||
if (current) {
|
||||
tokens.push({ value: current, isPhrase: currentIsPhrase });
|
||||
current = "";
|
||||
currentIsPhrase = false;
|
||||
}
|
||||
inQuotes = true;
|
||||
currentIsPhrase = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inQuotes && /\s/.test(char)) {
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
tokens.push({ value: current, isPhrase: currentIsPhrase });
|
||||
current = "";
|
||||
currentIsPhrase = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -67,10 +100,14 @@ const tokenizeQuery = (input) => {
|
||||
}
|
||||
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
tokens.push({ value: current, isPhrase: currentIsPhrase });
|
||||
}
|
||||
|
||||
return tokens;
|
||||
if (inQuotes) {
|
||||
sawUnclosedQuote = true;
|
||||
}
|
||||
|
||||
return { tokens, sawUnclosedQuote };
|
||||
};
|
||||
|
||||
const parseDateValue = (value) => {
|
||||
@@ -143,29 +180,37 @@ const formatDurationValue = (seconds) => {
|
||||
};
|
||||
|
||||
export function parseFilterQuery(inputString = "") {
|
||||
const normalized = normalizeWhitespace(String(inputString || ""));
|
||||
const tokens = tokenizeQuery(normalized);
|
||||
const normalized = String(inputString || "").trim();
|
||||
const { tokens, sawUnclosedQuote } = tokenizeQuery(normalized);
|
||||
const filters = cloneDefaultFilters();
|
||||
const errors = [];
|
||||
const textTokens = [];
|
||||
|
||||
if (sawUnclosedQuote) {
|
||||
errors.push({ token: "\"", message: "Missing closing quote." });
|
||||
}
|
||||
|
||||
for (const token of tokens) {
|
||||
if (!token) continue;
|
||||
const upperToken = token.toUpperCase();
|
||||
if (BOOLEAN_OPERATORS.has(upperToken)) {
|
||||
textTokens.push({ type: "operator", value: upperToken });
|
||||
if (!token?.value) continue;
|
||||
const upperToken = token.value.toUpperCase();
|
||||
if (!token.isPhrase && BOOLEAN_OPERATORS.has(upperToken)) {
|
||||
textTokens.push({ type: "operator", value: upperToken, isPhrase: false });
|
||||
continue;
|
||||
}
|
||||
|
||||
const keyValue = parseKeyValueToken(token);
|
||||
const keyValue = parseKeyValueToken(token.value);
|
||||
if (!keyValue) {
|
||||
textTokens.push({ type: "term", value: token, isPhrase: token.includes(" ") });
|
||||
textTokens.push({
|
||||
type: "term",
|
||||
value: token.value,
|
||||
isPhrase: Boolean(token.isPhrase || token.value.includes(" ")),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const { key, value } = keyValue;
|
||||
if (!value) {
|
||||
errors.push({ token, message: "Filter value is missing." });
|
||||
errors.push({ token: token.value, message: "Filter value is missing." });
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -173,7 +218,7 @@ export function parseFilterQuery(inputString = "") {
|
||||
case "author": {
|
||||
const values = value.split(",").map((entry) => entry.trim()).filter(Boolean);
|
||||
if (values.length === 0) {
|
||||
errors.push({ token, message: "Author value is empty." });
|
||||
errors.push({ token: token.value, message: "Author value is empty." });
|
||||
break;
|
||||
}
|
||||
filters.authorPubkeys.push(...values);
|
||||
@@ -185,7 +230,7 @@ export function parseFilterQuery(inputString = "") {
|
||||
.map((entry) => entry.trim().replace(/^#/, ""))
|
||||
.filter(Boolean);
|
||||
if (values.length === 0) {
|
||||
errors.push({ token, message: "Tag value is empty." });
|
||||
errors.push({ token: token.value, message: "Tag value is empty." });
|
||||
break;
|
||||
}
|
||||
filters.tags.push(...values);
|
||||
@@ -194,7 +239,7 @@ export function parseFilterQuery(inputString = "") {
|
||||
case "kind": {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
errors.push({ token, message: "Kind must be an integer." });
|
||||
errors.push({ token: token.value, message: "Kind must be an integer." });
|
||||
break;
|
||||
}
|
||||
filters.kind = parsed;
|
||||
@@ -207,7 +252,7 @@ export function parseFilterQuery(inputString = "") {
|
||||
case "after": {
|
||||
const parsed = parseDateValue(value);
|
||||
if (parsed.error) {
|
||||
errors.push({ token, message: parsed.error });
|
||||
errors.push({ token: token.value, message: parsed.error });
|
||||
break;
|
||||
}
|
||||
filters.dateRange.after = parsed.value;
|
||||
@@ -216,7 +261,7 @@ export function parseFilterQuery(inputString = "") {
|
||||
case "before": {
|
||||
const parsed = parseDateValue(value);
|
||||
if (parsed.error) {
|
||||
errors.push({ token, message: parsed.error });
|
||||
errors.push({ token: token.value, message: parsed.error });
|
||||
break;
|
||||
}
|
||||
filters.dateRange.before = parsed.value;
|
||||
@@ -225,14 +270,17 @@ export function parseFilterQuery(inputString = "") {
|
||||
case "duration": {
|
||||
const match = value.match(/^(<=|>=|<|>)(.+)$/);
|
||||
if (!match) {
|
||||
errors.push({ token, message: "Duration must use < or > operators." });
|
||||
errors.push({
|
||||
token: token.value,
|
||||
message: "Duration must use < or > operators.",
|
||||
});
|
||||
break;
|
||||
}
|
||||
const operator = match[1];
|
||||
const durationValue = match[2].trim();
|
||||
const parsed = parseDurationValue(durationValue);
|
||||
if (parsed.error) {
|
||||
errors.push({ token, message: parsed.error });
|
||||
errors.push({ token: token.value, message: parsed.error });
|
||||
break;
|
||||
}
|
||||
applyDurationFilter(filters, operator, parsed.value);
|
||||
@@ -244,8 +292,13 @@ export function parseFilterQuery(inputString = "") {
|
||||
filters.hasMagnet = true;
|
||||
} else if (normalizedValue === "url") {
|
||||
filters.hasUrl = true;
|
||||
} else if (normalizedValue === "transcript") {
|
||||
filters.hasTranscript = true;
|
||||
} else {
|
||||
errors.push({ token, message: "Has filter supports magnet or url." });
|
||||
errors.push({
|
||||
token: token.value,
|
||||
message: "Has filter supports magnet, url, or transcript.",
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -254,18 +307,37 @@ export function parseFilterQuery(inputString = "") {
|
||||
if (["any", "true", "false", "only", "safe"].includes(normalizedValue)) {
|
||||
filters.nsfw = normalizedValue === "safe" ? "false" : normalizedValue;
|
||||
} else {
|
||||
errors.push({ token, message: "NSFW filter supports any/true/false/only." });
|
||||
errors.push({
|
||||
token: token.value,
|
||||
message: "NSFW filter supports any/true/false/only.",
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "sort": {
|
||||
const normalizedValue = value.toLowerCase();
|
||||
if (!SORT_VALUES.has(normalizedValue)) {
|
||||
errors.push({ token: token.value, message: "Sort filter is invalid." });
|
||||
break;
|
||||
}
|
||||
filters.sort = normalizedValue;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
errors.push({ token, message: `Unknown filter "${key}".` });
|
||||
errors.push({ token: token.value, message: `Unknown filter "${key}".` });
|
||||
}
|
||||
}
|
||||
|
||||
const text = textTokens
|
||||
.filter((token) => token.type === "term")
|
||||
.map((token) => token.value)
|
||||
.map((token) => {
|
||||
if (token.type === "operator") {
|
||||
return token.value;
|
||||
}
|
||||
if (token.isPhrase) {
|
||||
return `"${token.value}"`;
|
||||
}
|
||||
return token.value;
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
return {
|
||||
@@ -323,10 +395,18 @@ export function serializeFiltersToQuery(filters = DEFAULT_FILTERS) {
|
||||
tokens.push("has:url");
|
||||
}
|
||||
|
||||
if (filters.hasTranscript === true) {
|
||||
tokens.push("has:transcript");
|
||||
}
|
||||
|
||||
if (filters.nsfw && filters.nsfw !== "any") {
|
||||
tokens.push(`nsfw:${filters.nsfw}`);
|
||||
}
|
||||
|
||||
if (filters.sort && filters.sort !== DEFAULT_FILTERS.sort) {
|
||||
tokens.push(`sort:${filters.sort}`);
|
||||
}
|
||||
|
||||
return tokens.join(" ");
|
||||
}
|
||||
|
||||
|
||||
+308
-17
@@ -10,6 +10,8 @@ import { devLogger } from "./utils/logger.js";
|
||||
import { ALLOW_NSFW_CONTENT } from "./config.js";
|
||||
import { attachFeedInfoPopover } from "./ui/components/FeedInfoPopover.js";
|
||||
import { setHashView } from "./hashView.js";
|
||||
import { sanitizeRelayList } from "./nostr/nip46Client.js";
|
||||
import { subscribeToVideoViewCount, unsubscribeFromVideoViewCount } from "./viewCounter.js";
|
||||
import {
|
||||
buildSearchHashFromState,
|
||||
getSearchFilterState,
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
setSearchFilterState,
|
||||
syncSearchFilterStateFromHash,
|
||||
} from "./search/searchFilterState.js";
|
||||
import { DEFAULT_FILTERS, SORT_OPTIONS } from "./search/searchFilters.js";
|
||||
|
||||
function getApp() {
|
||||
return getApplication();
|
||||
@@ -126,7 +129,7 @@ export async function initSearchView() {
|
||||
if (infoTrigger) {
|
||||
attachFeedInfoPopover(
|
||||
infoTrigger,
|
||||
"Results matching your search query. Use tokens like author:, tag:, kind:, relay:, after:, before:, duration:<, and has:magnet/url."
|
||||
"Results matching your search query. Use tokens like author:, tag:, kind:, relay:, after:, before:, duration:<, has:magnet/url/transcript, and boolean operators with quoted phrases."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -149,7 +152,7 @@ export async function initSearchView() {
|
||||
const searchToken = ++currentSearchToken;
|
||||
|
||||
// 1. Search Profiles
|
||||
performProfileSearch(query, searchToken).then(profiles => {
|
||||
performProfileSearch(query, searchToken, parsedQuery.filters).then(profiles => {
|
||||
if (searchToken !== currentSearchToken) return;
|
||||
renderProfileCards(profiles, channelList);
|
||||
}).catch(err => {
|
||||
@@ -193,6 +196,105 @@ const formatDurationLabel = (seconds) => {
|
||||
return `${seconds}s`;
|
||||
};
|
||||
|
||||
const resolveSortLabel = (sortValue) => {
|
||||
const option = SORT_OPTIONS.find((entry) => entry.value === sortValue);
|
||||
if (!option) return sortValue;
|
||||
const suffix = option.experimental ? " (experimental)" : "";
|
||||
return `${option.label}${suffix}`;
|
||||
};
|
||||
|
||||
const getVideoMetricValue = (video, candidates = []) => {
|
||||
if (!video || typeof video !== "object") {
|
||||
return null;
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue;
|
||||
const value = video[candidate];
|
||||
if (Number.isFinite(value)) {
|
||||
return Number(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getVideoZapCount = (video) => {
|
||||
const direct = getVideoMetricValue(video, [
|
||||
"zapCount",
|
||||
"zapTotal",
|
||||
"zapTotalSats",
|
||||
"zapTotalMsats",
|
||||
]);
|
||||
if (direct !== null) {
|
||||
return direct;
|
||||
}
|
||||
if (video?.zaps && typeof video.zaps === "object") {
|
||||
const nested = getVideoMetricValue(video.zaps, ["count", "total"]);
|
||||
if (nested !== null) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getVideoReactionCount = (video) => {
|
||||
const direct = getVideoMetricValue(video, [
|
||||
"reactionCount",
|
||||
"reactionsCount",
|
||||
"commentCount",
|
||||
"commentsCount",
|
||||
]);
|
||||
if (direct !== null) {
|
||||
return direct;
|
||||
}
|
||||
if (video?.reactions && typeof video.reactions === "object") {
|
||||
const nested = getVideoMetricValue(video.reactions, ["total", "count"]);
|
||||
if (nested !== null) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getViewCountSnapshot = (video, app, relays) => {
|
||||
if (!video) {
|
||||
return { total: null, known: false };
|
||||
}
|
||||
const pointerInfo =
|
||||
typeof app?.deriveVideoPointerInfo === "function"
|
||||
? app.deriveVideoPointerInfo(video)
|
||||
: null;
|
||||
if (!pointerInfo?.pointer) {
|
||||
return { total: null, known: false };
|
||||
}
|
||||
let snapshot = null;
|
||||
let token = null;
|
||||
try {
|
||||
token = subscribeToVideoViewCount(
|
||||
pointerInfo.pointer,
|
||||
(next) => {
|
||||
snapshot = next;
|
||||
},
|
||||
relays?.length ? { relays } : undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
devLogger.warn("[Search] Unable to subscribe to view count.", error);
|
||||
return { total: null, known: false };
|
||||
} finally {
|
||||
if (token) {
|
||||
try {
|
||||
unsubscribeFromVideoViewCount(pointerInfo.pointer, token);
|
||||
} catch (error) {
|
||||
devLogger.warn("[Search] Unable to unsubscribe from view count.", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const total = Number.isFinite(snapshot?.total) ? Number(snapshot.total) : null;
|
||||
const known =
|
||||
Number.isFinite(snapshot?.lastSyncedAt) && snapshot.lastSyncedAt > 0;
|
||||
return { total, known };
|
||||
};
|
||||
|
||||
function buildNextFilters() {
|
||||
const current = getSearchFilterState();
|
||||
const filters = current.filters || {};
|
||||
@@ -340,6 +442,17 @@ function renderActiveFilters(filters) {
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.hasTranscript === true) {
|
||||
pills.push({
|
||||
label: "Has transcript",
|
||||
onRemove: () => {
|
||||
const nextFilters = buildNextFilters();
|
||||
nextFilters.hasTranscript = null;
|
||||
applyFiltersAndRefresh(nextFilters);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.nsfw && filters.nsfw !== "any") {
|
||||
pills.push({
|
||||
label: `NSFW: ${filters.nsfw}`,
|
||||
@@ -351,6 +464,17 @@ function renderActiveFilters(filters) {
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.sort && filters.sort !== DEFAULT_FILTERS.sort) {
|
||||
pills.push({
|
||||
label: `Sort: ${resolveSortLabel(filters.sort)}`,
|
||||
onRemove: () => {
|
||||
const nextFilters = buildNextFilters();
|
||||
nextFilters.sort = DEFAULT_FILTERS.sort;
|
||||
applyFiltersAndRefresh(nextFilters);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!pills.length) {
|
||||
wrapper.classList.add("hidden");
|
||||
return;
|
||||
@@ -484,11 +608,18 @@ async function renderSearchVideos(videos, container, app) {
|
||||
container.appendChild(fragment);
|
||||
}
|
||||
|
||||
async function performProfileSearch(query, token) {
|
||||
async function performProfileSearch(query, token, filters = {}) {
|
||||
if (!query) return [];
|
||||
|
||||
const normalizedQuery = query.toLowerCase();
|
||||
const uniqueProfiles = new Map();
|
||||
const authorFilterSet = new Set(
|
||||
Array.isArray(filters?.authorPubkeys)
|
||||
? filters.authorPubkeys.map((author) => String(author || "").toLowerCase())
|
||||
: []
|
||||
);
|
||||
const afterDate = Number.isFinite(filters?.dateRange?.after) ? filters.dateRange.after : null;
|
||||
const beforeDate = Number.isFinite(filters?.dateRange?.before) ? filters.dateRange.before : null;
|
||||
|
||||
// 1. Local Search (Scan known authors in cache)
|
||||
if (nostrClient?.activeMap instanceof Map) {
|
||||
@@ -537,13 +668,38 @@ async function performProfileSearch(query, token) {
|
||||
|
||||
// 2. Relay Search (NIP-50)
|
||||
if (nostrClient && nostrClient.pool) {
|
||||
const relays = nostrClient.relays || [];
|
||||
const relaySelection = filters?.relay ? sanitizeRelayList([filters.relay]) : null;
|
||||
const relays = relaySelection?.length
|
||||
? relaySelection
|
||||
: Array.isArray(nostrClient.relays)
|
||||
? nostrClient.relays
|
||||
: [];
|
||||
if (relays.length > 0) {
|
||||
const filter = {
|
||||
kinds: [0],
|
||||
kinds: [Number.isFinite(filters?.kind) ? filters.kind : 0],
|
||||
search: query,
|
||||
limit: 20
|
||||
};
|
||||
const authorFilters = Array.isArray(filters?.authorPubkeys)
|
||||
? filters.authorPubkeys.filter((author) => typeof author === "string" && author)
|
||||
: [];
|
||||
if (authorFilters.length > 0) {
|
||||
filter.authors = authorFilters;
|
||||
}
|
||||
if (Number.isFinite(afterDate)) {
|
||||
filter.since = afterDate;
|
||||
}
|
||||
if (Number.isFinite(beforeDate)) {
|
||||
filter.until = beforeDate;
|
||||
}
|
||||
const tagFilters = Array.isArray(filters?.tags)
|
||||
? filters.tags
|
||||
.map((tag) => String(tag || "").trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
if (tagFilters.length > 0) {
|
||||
filter["#t"] = Array.from(new Set(tagFilters));
|
||||
}
|
||||
|
||||
try {
|
||||
const events = await nostrClient.pool.list(relays, [filter]);
|
||||
@@ -575,7 +731,20 @@ async function performProfileSearch(query, token) {
|
||||
if (currentSearchToken !== token) return [];
|
||||
|
||||
// Convert map to array and sort by something relevant (maybe creation time or exact match?)
|
||||
return Array.from(uniqueProfiles.values());
|
||||
return Array.from(uniqueProfiles.values()).filter((profile) => {
|
||||
if (!profile) return false;
|
||||
if (authorFilterSet.size > 0) {
|
||||
const pubkey = typeof profile.pubkey === "string" ? profile.pubkey.toLowerCase() : "";
|
||||
if (!pubkey || !authorFilterSet.has(pubkey)) return false;
|
||||
}
|
||||
if (Number.isFinite(afterDate) && (!Number.isFinite(profile.created_at) || profile.created_at < afterDate)) {
|
||||
return false;
|
||||
}
|
||||
if (Number.isFinite(beforeDate) && (!Number.isFinite(profile.created_at) || profile.created_at > beforeDate)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function performVideoSearch(query, token, filters = {}) {
|
||||
@@ -613,9 +782,10 @@ async function performVideoSearch(query, token, filters = {}) {
|
||||
: null;
|
||||
const hasMagnet = filters?.hasMagnet === true;
|
||||
const hasUrl = filters?.hasUrl === true;
|
||||
const hasTranscript = filters?.hasTranscript === true;
|
||||
const nsfwFilter = typeof filters?.nsfw === "string" ? filters.nsfw : "any";
|
||||
|
||||
const matchesCustomFilters = (video) => {
|
||||
const matchesRelayFilters = (video) => {
|
||||
if (!video) return false;
|
||||
|
||||
if (authorSet.size > 0) {
|
||||
@@ -641,8 +811,20 @@ async function performVideoSearch(query, token, filters = {}) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const matchesUnsupportedFilters = (video) => {
|
||||
if (!video) return false;
|
||||
|
||||
if (hasMagnet && !video.magnet) return false;
|
||||
if (hasUrl && !video.url) return false;
|
||||
if (hasTranscript) {
|
||||
const textTracks = Array.isArray(video.nip71?.textTracks)
|
||||
? video.nip71.textTracks
|
||||
: [];
|
||||
if (!textTracks.length) return false;
|
||||
}
|
||||
|
||||
if (Number.isFinite(minDuration) || Number.isFinite(maxDuration)) {
|
||||
const duration = Number(video.nip71?.duration);
|
||||
@@ -696,23 +878,49 @@ async function performVideoSearch(query, token, filters = {}) {
|
||||
|
||||
if (currentSearchToken !== token) return [];
|
||||
|
||||
const filteredLocalMatches = applyAccessFilters(localMatches).filter(matchesCustomFilters);
|
||||
const filteredLocalMatches = applyAccessFilters(localMatches).filter(
|
||||
(video) => matchesRelayFilters(video) && matchesUnsupportedFilters(video)
|
||||
);
|
||||
|
||||
// 2. Relay Search
|
||||
let relayVideos = [];
|
||||
const relays = nostrClient.relays || [];
|
||||
const relaySelection = filters?.relay ? sanitizeRelayList([filters.relay]) : null;
|
||||
const relays = relaySelection?.length
|
||||
? relaySelection
|
||||
: Array.isArray(nostrClient.relays)
|
||||
? nostrClient.relays
|
||||
: [];
|
||||
|
||||
if (relays.length > 0 && nostrClient.pool) {
|
||||
const filter = {
|
||||
kinds: [30078],
|
||||
kinds: [Number.isFinite(filters?.kind) ? filters.kind : 30078],
|
||||
limit: 50
|
||||
};
|
||||
|
||||
if (isHashtag) {
|
||||
filter["#t"] = [term];
|
||||
} else {
|
||||
filter.search = term;
|
||||
const authorFilters = Array.isArray(filters?.authorPubkeys)
|
||||
? filters.authorPubkeys.filter((author) => typeof author === "string" && author)
|
||||
: [];
|
||||
if (authorFilters.length > 0) {
|
||||
filter.authors = authorFilters;
|
||||
}
|
||||
if (Number.isFinite(afterDate)) {
|
||||
filter.since = afterDate;
|
||||
}
|
||||
if (Number.isFinite(beforeDate)) {
|
||||
filter.until = beforeDate;
|
||||
}
|
||||
const tagFilters = new Set(
|
||||
Array.isArray(filters?.tags)
|
||||
? filters.tags.map((tag) => String(tag || "").trim().toLowerCase()).filter(Boolean)
|
||||
: []
|
||||
);
|
||||
if (isHashtag && term) {
|
||||
tagFilters.add(term.toLowerCase());
|
||||
}
|
||||
if (tagFilters.size > 0) {
|
||||
filter["#t"] = Array.from(tagFilters);
|
||||
}
|
||||
filter.search = term;
|
||||
|
||||
try {
|
||||
const events = await nostrClient.pool.list(relays, [filter]);
|
||||
@@ -733,7 +941,9 @@ async function performVideoSearch(query, token, filters = {}) {
|
||||
|
||||
if (currentSearchToken !== token) return [];
|
||||
|
||||
const filteredRelayVideos = applyAccessFilters(relayVideos).filter(matchesCustomFilters);
|
||||
const filteredRelayVideos = applyAccessFilters(relayVideos).filter(
|
||||
(video) => matchesRelayFilters(video) && matchesUnsupportedFilters(video)
|
||||
);
|
||||
|
||||
// Deduplicate by ID
|
||||
const unique = new Map();
|
||||
@@ -748,6 +958,87 @@ 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));
|
||||
const entries = Array.from(unique.values()).map((video, index) => ({
|
||||
video,
|
||||
index,
|
||||
createdAt: Number.isFinite(video?.created_at) ? Number(video.created_at) : 0
|
||||
}));
|
||||
|
||||
const sortByNewest = () =>
|
||||
entries
|
||||
.slice()
|
||||
.sort((a, b) => (b.createdAt - a.createdAt) || (a.index - b.index));
|
||||
|
||||
const sortByOldest = () =>
|
||||
entries
|
||||
.slice()
|
||||
.sort((a, b) => (a.createdAt - b.createdAt) || (a.index - b.index));
|
||||
|
||||
const sortByMetric = (metricKey, metricResolver) => {
|
||||
let hasUnknown = false;
|
||||
const enriched = entries.map((entry) => {
|
||||
const metricValue = metricResolver(entry.video);
|
||||
if (!Number.isFinite(metricValue)) {
|
||||
hasUnknown = true;
|
||||
}
|
||||
return {
|
||||
...entry,
|
||||
[metricKey]: Number.isFinite(metricValue) ? Number(metricValue) : 0
|
||||
};
|
||||
});
|
||||
|
||||
if (hasUnknown) {
|
||||
return sortByNewest();
|
||||
}
|
||||
|
||||
return enriched
|
||||
.sort((a, b) =>
|
||||
(b[metricKey] - a[metricKey]) ||
|
||||
(b.createdAt - a.createdAt) ||
|
||||
(a.index - b.index)
|
||||
);
|
||||
};
|
||||
|
||||
const sortByViews = () => {
|
||||
let hasUnknown = false;
|
||||
const viewCountRelays = relaySelection?.length ? relaySelection : null;
|
||||
const enriched = entries.map((entry) => {
|
||||
const snapshot = getViewCountSnapshot(entry.video, app, viewCountRelays);
|
||||
if (!snapshot.known || !Number.isFinite(snapshot.total)) {
|
||||
hasUnknown = true;
|
||||
}
|
||||
return {
|
||||
...entry,
|
||||
viewCount: Number.isFinite(snapshot.total) ? Number(snapshot.total) : 0
|
||||
};
|
||||
});
|
||||
|
||||
if (hasUnknown) {
|
||||
return sortByNewest();
|
||||
}
|
||||
|
||||
return enriched
|
||||
.sort((a, b) =>
|
||||
(b.viewCount - a.viewCount) ||
|
||||
(b.createdAt - a.createdAt) ||
|
||||
(a.index - b.index)
|
||||
);
|
||||
};
|
||||
|
||||
const sortMode = typeof filters?.sort === "string" ? filters.sort : DEFAULT_FILTERS.sort;
|
||||
let sortedEntries = sortByNewest();
|
||||
|
||||
if (sortMode === "oldest") {
|
||||
sortedEntries = sortByOldest();
|
||||
} else if (sortMode === "views") {
|
||||
sortedEntries = sortByViews();
|
||||
} else if (sortMode === "zaps") {
|
||||
sortedEntries = sortByMetric("zapCount", getVideoZapCount);
|
||||
} else if (sortMode === "reactions") {
|
||||
sortedEntries = sortByMetric("reactionCount", getVideoReactionCount);
|
||||
} else if (sortMode === "newest" || sortMode === "relevance") {
|
||||
sortedEntries = sortByNewest();
|
||||
}
|
||||
|
||||
return sortedEntries.map((entry) => entry.video);
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import createPopover from "../overlay/popoverEngine.js";
|
||||
import { DEFAULT_FILTERS } from "../../search/searchFilters.js";
|
||||
import {
|
||||
DEFAULT_FILTERS,
|
||||
SORT_OPTIONS,
|
||||
parseFilterQuery,
|
||||
} from "../../search/searchFilters.js";
|
||||
|
||||
const FOCUSABLE_SELECTOR =
|
||||
"a[href], button:not([disabled]), input:not([disabled]):not([type=\"hidden\"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])";
|
||||
@@ -187,6 +191,12 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
|
||||
header.append(heading, closeButton);
|
||||
panel.appendChild(header);
|
||||
|
||||
const parseMessage = createElement(doc, "p", {
|
||||
className: "text-xs text-warning hidden",
|
||||
attrs: { role: "status", "aria-live": "polite" },
|
||||
});
|
||||
panel.appendChild(parseMessage);
|
||||
|
||||
const dateSection = createElement(doc, "div", {
|
||||
className: "bv-stack bv-stack--tight",
|
||||
});
|
||||
@@ -276,14 +286,16 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
|
||||
className: "select w-full",
|
||||
attrs: { "aria-label": "Sort results" },
|
||||
});
|
||||
[
|
||||
{ label: "Most recent", value: "recent" },
|
||||
{ label: "Most viewed", value: "views" },
|
||||
{ label: "Trending", value: "trending" },
|
||||
{ label: "Longest", value: "longest" },
|
||||
].forEach(({ label, value }) => {
|
||||
const option = createElement(doc, "option", { text: label });
|
||||
option.value = value;
|
||||
const enableExperimentalSorts = options.enableExperimentalSorts === true;
|
||||
SORT_OPTIONS.forEach((sortOption) => {
|
||||
const suffix = sortOption.experimental ? " (experimental)" : "";
|
||||
const option = createElement(doc, "option", {
|
||||
text: `${sortOption.label}${suffix}`,
|
||||
});
|
||||
option.value = sortOption.value;
|
||||
if (sortOption.experimental && !enableExperimentalSorts) {
|
||||
option.disabled = true;
|
||||
}
|
||||
sortSelect.appendChild(option);
|
||||
});
|
||||
sortSection.appendChild(sortSelect);
|
||||
@@ -297,7 +309,21 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
|
||||
placeholder: "npub1...",
|
||||
},
|
||||
);
|
||||
panel.appendChild(authorWrapper);
|
||||
const { wrapper: kindWrapper, inputEl: kindInput } = buildLabeledInput(doc, {
|
||||
id: "search-filters-kind",
|
||||
label: "Kind",
|
||||
type: "number",
|
||||
placeholder: "30078",
|
||||
});
|
||||
const { wrapper: relayWrapper, inputEl: relayInput } = buildLabeledInput(
|
||||
doc,
|
||||
{
|
||||
id: "search-filters-relay",
|
||||
label: "Relay",
|
||||
placeholder: "wss://relay.example.com",
|
||||
},
|
||||
);
|
||||
panel.append(authorWrapper, kindWrapper, relayWrapper);
|
||||
|
||||
const tagSection = createElement(doc, "div", {
|
||||
className: "bv-stack bv-stack--tight",
|
||||
@@ -364,6 +390,37 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
|
||||
durationSection.appendChild(durationGrid);
|
||||
panel.appendChild(durationSection);
|
||||
|
||||
const hasSection = createElement(doc, "div", {
|
||||
className: "bv-stack bv-stack--tight",
|
||||
});
|
||||
hasSection.appendChild(
|
||||
createElement(doc, "p", {
|
||||
className: "text-xs font-semibold uppercase tracking-wide text-muted",
|
||||
text: "Has",
|
||||
}),
|
||||
);
|
||||
const hasChipRow = createElement(doc, "div", {
|
||||
className: "flex flex-wrap gap-2",
|
||||
});
|
||||
const hasChips = new Map();
|
||||
[
|
||||
{ key: "magnet", label: "Magnet" },
|
||||
{ key: "url", label: "URL" },
|
||||
{ key: "transcript", label: "Transcript" },
|
||||
].forEach(({ key, label }) => {
|
||||
const chip = createElement(doc, "button", {
|
||||
className: "search-filter-chip focus-ring",
|
||||
text: label,
|
||||
attrs: { type: "button", "aria-pressed": "false" },
|
||||
});
|
||||
chip.dataset.state = "off";
|
||||
chip.addEventListener("click", () => toggleChip(chip));
|
||||
hasChips.set(key, chip);
|
||||
hasChipRow.appendChild(chip);
|
||||
});
|
||||
hasSection.appendChild(hasChipRow);
|
||||
panel.appendChild(hasSection);
|
||||
|
||||
const toggleSection = createElement(doc, "div", {
|
||||
className: "grid gap-3",
|
||||
});
|
||||
@@ -459,8 +516,22 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
|
||||
followedSwitch.classList.remove("is-on");
|
||||
}
|
||||
if (sortSelect) {
|
||||
sortSelect.selectedIndex = 0;
|
||||
sortSelect.value = safeFilters.sort || DEFAULT_FILTERS.sort;
|
||||
}
|
||||
kindInput.value = Number.isFinite(safeFilters.kind)
|
||||
? String(safeFilters.kind)
|
||||
: "";
|
||||
relayInput.value = safeFilters.relay || "";
|
||||
const hasFlags = {
|
||||
magnet: safeFilters.hasMagnet === true,
|
||||
url: safeFilters.hasUrl === true,
|
||||
transcript: safeFilters.hasTranscript === true,
|
||||
};
|
||||
hasChips.forEach((chip, key) => {
|
||||
const isOn = hasFlags[key] === true;
|
||||
chip.setAttribute("aria-pressed", isOn ? "true" : "false");
|
||||
chip.dataset.state = isOn ? "on" : "off";
|
||||
});
|
||||
};
|
||||
|
||||
const buildFiltersFromControls = () => {
|
||||
@@ -476,6 +547,9 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
filters.authorPubkeys = authorValues;
|
||||
const kindValue = Number.parseInt(kindInput.value, 10);
|
||||
filters.kind = Number.isFinite(kindValue) ? kindValue : null;
|
||||
filters.relay = relayInput.value.trim() || null;
|
||||
const selectedTags = new Set();
|
||||
tagChips.forEach((chip, tag) => {
|
||||
if (chip.getAttribute("aria-pressed") === "true") {
|
||||
@@ -510,6 +584,21 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
|
||||
if (nsfwSwitch?.getAttribute("aria-checked") === "true") {
|
||||
filters.nsfw = "true";
|
||||
}
|
||||
if (sortSelect?.value) {
|
||||
filters.sort = sortSelect.value;
|
||||
}
|
||||
filters.hasMagnet =
|
||||
hasChips.get("magnet")?.getAttribute("aria-pressed") === "true"
|
||||
? true
|
||||
: null;
|
||||
filters.hasUrl =
|
||||
hasChips.get("url")?.getAttribute("aria-pressed") === "true"
|
||||
? true
|
||||
: null;
|
||||
filters.hasTranscript =
|
||||
hasChips.get("transcript")?.getAttribute("aria-pressed") === "true"
|
||||
? true
|
||||
: null;
|
||||
return filters;
|
||||
};
|
||||
|
||||
@@ -533,6 +622,24 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
|
||||
|
||||
controlState = {
|
||||
applyStateToControls,
|
||||
setParseErrors: (errors = []) => {
|
||||
if (!parseMessage) return;
|
||||
if (!errors?.length) {
|
||||
parseMessage.textContent = "";
|
||||
parseMessage.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
const message = errors
|
||||
.map((error) =>
|
||||
error?.token
|
||||
? `${error.token} (${error.message})`
|
||||
: error?.message,
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(" • ");
|
||||
parseMessage.textContent = message || "Some filters could not be parsed.";
|
||||
parseMessage.classList.remove("hidden");
|
||||
},
|
||||
};
|
||||
return panel;
|
||||
};
|
||||
@@ -543,6 +650,23 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
|
||||
restoreFocusOnClose: true,
|
||||
});
|
||||
|
||||
const syncFromQueryInput = (rawQuery) => {
|
||||
if (typeof rawQuery !== "string") {
|
||||
return { filters: DEFAULT_FILTERS, text: "", tokens: [], errors: [] };
|
||||
}
|
||||
const parsed = parseFilterQuery(rawQuery);
|
||||
if (controlState?.applyStateToControls) {
|
||||
controlState.applyStateToControls(parsed.filters || DEFAULT_FILTERS);
|
||||
}
|
||||
if (controlState?.setParseErrors) {
|
||||
controlState.setParseErrors(parsed.errors || []);
|
||||
}
|
||||
if (typeof options.onQueryParsed === "function") {
|
||||
options.onQueryParsed(parsed);
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
triggerElement.addEventListener("click", async (event) => {
|
||||
event.preventDefault();
|
||||
if (popover.isOpen()) {
|
||||
@@ -550,7 +674,13 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
|
||||
return;
|
||||
}
|
||||
popover.preload();
|
||||
if (controlState?.applyStateToControls) {
|
||||
const rawQuery =
|
||||
typeof options.getQueryInputValue === "function"
|
||||
? options.getQueryInputValue()
|
||||
: null;
|
||||
if (rawQuery !== null) {
|
||||
syncFromQueryInput(rawQuery);
|
||||
} else if (controlState?.applyStateToControls) {
|
||||
const nextState =
|
||||
typeof options.getState === "function" ? options.getState() : null;
|
||||
controlState.applyStateToControls(nextState?.filters || DEFAULT_FILTERS);
|
||||
@@ -558,6 +688,8 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
|
||||
await popover.open();
|
||||
});
|
||||
|
||||
popover.syncFromQueryInput = syncFromQueryInput;
|
||||
|
||||
return popover;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user