Files
bitvid/js/searchView.js
T

1045 lines
35 KiB
JavaScript

// js/searchView.js
import { nostrClient } from "./nostrClientFacade.js";
import { getApplication } from "./applicationContext.js";
import { renderChannelVideosFromList } from "./channelProfile.js";
import { escapeHTML } from "./utils/domUtils.js";
import { formatShortNpub } from "./utils/formatters.js";
import { sanitizeProfileMediaUrl } from "./utils/profileMedia.js";
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,
resetSearchFilters,
setSearchFilterState,
syncSearchFilterStateFromHash,
} from "./search/searchFilterState.js";
import { DEFAULT_FILTERS, SORT_OPTIONS } from "./search/searchFilters.js";
function getApp() {
return getApplication();
}
const FALLBACK_AVATAR = "assets/svg/default-profile.svg";
function renderProfileCards(profiles, container) {
if (!container) return;
container.innerHTML = "";
// Reset the "Show More" button and container limit state
const showMoreContainer = document.getElementById("searchChannelShowMore");
if (showMoreContainer) {
showMoreContainer.classList.add("hidden");
}
container.classList.remove("channel-grid-limit");
if (!profiles || profiles.length === 0) {
container.innerHTML = `<p class="text-sm text-muted col-span-full">No matching channels found.</p>`;
return;
}
// If we have more than 5 results (the smallest limit), we might need to show the button
// depending on screen size, but for simplicity we just enable it if > 5.
if (profiles.length > 5) {
container.classList.add("channel-grid-limit");
if (showMoreContainer) {
showMoreContainer.classList.remove("hidden");
const btn = showMoreContainer.querySelector("button");
if (btn) {
btn.onclick = () => {
container.classList.remove("channel-grid-limit");
showMoreContainer.classList.add("hidden");
};
}
}
}
const fragment = document.createDocumentFragment();
for (const profile of profiles) {
const pubkey = profile.pubkey;
const metadata = profile.metadata || {};
const name = metadata.name || metadata.display_name || "Unknown User";
const picture = sanitizeProfileMediaUrl(metadata.picture) || FALLBACK_AVATAR;
const about = metadata.about || "";
let npub = "";
try {
if (window.NostrTools && window.NostrTools.nip19) {
npub = window.NostrTools.nip19.npubEncode(pubkey);
}
} catch (e) {
// ignore
}
const shortNpub = formatShortNpub(npub) || "";
const card = document.createElement("a");
card.href = `#view=channel-profile&npub=${npub || pubkey}`;
card.className = "flex items-center gap-3 p-3 rounded-lg bg-surface hover:bg-surface-elevated transition-colors border border-border group";
card.innerHTML = `
<div class="relative w-12 h-12 flex-shrink-0 rounded-full overflow-hidden bg-background">
<img
src="${escapeHTML(picture)}"
alt="${escapeHTML(name)}"
class="w-full h-full object-cover"
loading="lazy"
onerror="this.src='${FALLBACK_AVATAR}'"
/>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-sm font-semibold text-text-strong truncate group-hover:text-primary transition-colors">
${escapeHTML(name)}
</h3>
<p class="text-xs text-muted truncate">
${escapeHTML(shortNpub)}
</p>
</div>
`;
fragment.appendChild(card);
}
container.appendChild(fragment);
}
let currentSearchToken = 0;
export async function initSearchView() {
const parsedQuery = syncSearchFilterStateFromHash(window.location.hash);
if (parsedQuery.errors.length > 0) {
devLogger.warn("[Search] Filter parsing errors", parsedQuery.errors);
}
const query = parsedQuery.text || "";
renderActiveFilters(parsedQuery.filters);
const titleEl = document.getElementById("searchTitle");
if (titleEl) {
titleEl.textContent = query ? `Search Results for "${query}"` : "Search Results";
}
const infoTrigger = document.getElementById("searchInfoTrigger");
if (infoTrigger) {
attachFeedInfoPopover(
infoTrigger,
"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."
);
}
const channelList = document.getElementById("searchChannelList");
const videoList = document.getElementById("searchVideoList");
if (channelList) {
channelList.innerHTML = `<p class="text-sm text-muted animate-pulse col-span-full">Searching channels...</p>`;
}
if (videoList) {
videoList.innerHTML = `<p class="text-sm text-muted animate-pulse col-span-full">Searching videos...</p>`;
}
if (!query) {
if (channelList) channelList.innerHTML = `<p class="text-sm text-muted col-span-full">Please enter a search term.</p>`;
if (videoList) videoList.innerHTML = `<p class="text-sm text-muted col-span-full">Please enter a search term.</p>`;
return;
}
const searchToken = ++currentSearchToken;
// 1. Search Profiles
performProfileSearch(query, searchToken, parsedQuery.filters).then(profiles => {
if (searchToken !== currentSearchToken) return;
renderProfileCards(profiles, channelList);
}).catch(err => {
devLogger.warn("[Search] Profile search failed", err);
if (channelList && searchToken === currentSearchToken) {
channelList.innerHTML = `<p class="text-sm text-critical col-span-full">Failed to load channels.</p>`;
}
});
// 2. Search Videos
performVideoSearch(query, searchToken, parsedQuery.filters).then(videos => {
if (searchToken !== currentSearchToken) return;
const app = getApp();
renderSearchVideos(videos, videoList, app);
}).catch(err => {
devLogger.warn("[Search] Video search failed", err);
if (videoList && searchToken === currentSearchToken) {
videoList.innerHTML = `<p class="text-sm text-critical col-span-full">Failed to load videos.</p>`;
}
});
}
const formatDateLabel = (timestampSeconds) => {
if (!Number.isFinite(timestampSeconds)) return "";
const formatter = new Intl.DateTimeFormat("en-CA", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
return formatter.format(new Date(timestampSeconds * 1000));
};
const formatDurationLabel = (seconds) => {
if (!Number.isFinite(seconds)) return "";
if (seconds % 3600 === 0) {
return `${seconds / 3600}h`;
}
if (seconds % 60 === 0) {
return `${seconds / 60}m`;
}
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 || {};
return {
...filters,
dateRange: { ...filters.dateRange },
duration: { ...filters.duration },
authorPubkeys: Array.isArray(filters.authorPubkeys)
? [...filters.authorPubkeys]
: [],
tags: Array.isArray(filters.tags) ? [...filters.tags] : [],
};
}
function applyFiltersAndRefresh(nextFilters) {
const current = getSearchFilterState();
const nextState = {
text: current.text || "",
filters: nextFilters,
};
setSearchFilterState(nextState);
setHashView(buildSearchHashFromState(nextState));
}
function renderActiveFilters(filters) {
const wrapper = document.getElementById("searchActiveFilters");
const list = document.getElementById("searchActiveFiltersList");
const clearBtn = document.getElementById("searchActiveFiltersClear");
if (!wrapper || !list || !clearBtn) {
return;
}
list.innerHTML = "";
const pills = [];
filters.authorPubkeys?.forEach((author) => {
pills.push({
label: `Author: ${author}`,
onRemove: () => {
const nextFilters = buildNextFilters();
nextFilters.authorPubkeys = nextFilters.authorPubkeys.filter(
(value) => value !== author,
);
applyFiltersAndRefresh(nextFilters);
},
});
});
filters.tags?.forEach((tag) => {
pills.push({
label: `#${tag}`,
onRemove: () => {
const nextFilters = buildNextFilters();
nextFilters.tags = nextFilters.tags.filter((value) => value !== tag);
applyFiltersAndRefresh(nextFilters);
},
});
});
if (Number.isFinite(filters.kind)) {
pills.push({
label: `Kind: ${filters.kind}`,
onRemove: () => {
const nextFilters = buildNextFilters();
nextFilters.kind = null;
applyFiltersAndRefresh(nextFilters);
},
});
}
if (filters.relay) {
pills.push({
label: `Relay: ${filters.relay}`,
onRemove: () => {
const nextFilters = buildNextFilters();
nextFilters.relay = null;
applyFiltersAndRefresh(nextFilters);
},
});
}
if (Number.isFinite(filters.dateRange?.after)) {
pills.push({
label: `After: ${formatDateLabel(filters.dateRange.after)}`,
onRemove: () => {
const nextFilters = buildNextFilters();
nextFilters.dateRange.after = null;
applyFiltersAndRefresh(nextFilters);
},
});
}
if (Number.isFinite(filters.dateRange?.before)) {
pills.push({
label: `Before: ${formatDateLabel(filters.dateRange.before)}`,
onRemove: () => {
const nextFilters = buildNextFilters();
nextFilters.dateRange.before = null;
applyFiltersAndRefresh(nextFilters);
},
});
}
if (Number.isFinite(filters.duration?.minSeconds)) {
pills.push({
label: `Min duration: ${formatDurationLabel(filters.duration.minSeconds)}`,
onRemove: () => {
const nextFilters = buildNextFilters();
nextFilters.duration.minSeconds = null;
applyFiltersAndRefresh(nextFilters);
},
});
}
if (Number.isFinite(filters.duration?.maxSeconds)) {
pills.push({
label: `Max duration: ${formatDurationLabel(filters.duration.maxSeconds)}`,
onRemove: () => {
const nextFilters = buildNextFilters();
nextFilters.duration.maxSeconds = null;
applyFiltersAndRefresh(nextFilters);
},
});
}
if (filters.hasMagnet === true) {
pills.push({
label: "Has magnet",
onRemove: () => {
const nextFilters = buildNextFilters();
nextFilters.hasMagnet = null;
applyFiltersAndRefresh(nextFilters);
},
});
}
if (filters.hasUrl === true) {
pills.push({
label: "Has URL",
onRemove: () => {
const nextFilters = buildNextFilters();
nextFilters.hasUrl = null;
applyFiltersAndRefresh(nextFilters);
},
});
}
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}`,
onRemove: () => {
const nextFilters = buildNextFilters();
nextFilters.nsfw = "any";
applyFiltersAndRefresh(nextFilters);
},
});
}
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;
}
const fragment = document.createDocumentFragment();
pills.forEach((pill) => {
const button = document.createElement("button");
button.type = "button";
button.className =
"search-filter-chip inline-flex items-center gap-2 text-xs";
const label = document.createElement("span");
label.textContent = pill.label;
const remove = document.createElement("span");
remove.setAttribute("aria-hidden", "true");
remove.textContent = "✕";
button.append(label, remove);
button.addEventListener("click", pill.onRemove);
fragment.appendChild(button);
});
list.appendChild(fragment);
wrapper.classList.remove("hidden");
clearBtn.onclick = () => {
resetSearchFilters();
const nextState = getSearchFilterState();
setHashView(buildSearchHashFromState(nextState));
};
}
async function renderSearchVideos(videos, container, app) {
if (!container) return;
container.innerHTML = "";
if (!videos || videos.length === 0) {
container.innerHTML = `<p class="text-sm text-muted col-span-full">No matching videos found.</p>`;
return;
}
// Dynamic import VideoCard to avoid circular dependencies if any
const { VideoCard } = await import("./ui/components/VideoCard.js");
const { escapeHTML } = await import("./utils/domUtils.js"); // Ensure availability
const fragment = document.createDocumentFragment();
// We need some helpers from app or utils
const loadedThumbnails = app?.loadedThumbnails instanceof Map ? app.loadedThumbnails : new Map();
let index = 0;
for (const video of videos) {
if (!video) continue;
// Basic decoration if needed (e.g. resolve author info if not present)
// For search results, we might not have full author profiles loaded.
// We will do best effort.
const shareUrl = app?.buildShareUrlFromEventId ? app.buildShareUrlFromEventId(video.id) : "#";
const timeAgo = new Date(video.created_at * 1000).toLocaleString();
// Construct identity object
const identity = {
name: video.authorName || "",
picture: video.authorPicture || "",
pubkey: video.pubkey,
npub: video.authorNpub || "",
shortNpub: video.shortNpub || ""
};
// If we have access to the profile cache, we could enrich this.
// For now, let's rely on what's in the video object or defaults.
const videoCard = new VideoCard({
document,
video,
index: index++,
shareUrl,
timeAgo,
cardState: "", // default
identity,
capabilities: {
canEdit: false,
canDelete: false
},
nsfwContext: {
isNsfw: video.isNsfw,
allowNsfw: true, // or import config
viewerIsOwner: false
},
helpers: {
escapeHtml: escapeHTML,
isMagnetSupported: (magnet) => magnet && magnet.startsWith("magnet:"),
toLocaleString: (val) => val.toLocaleString()
},
assets: {
fallbackThumbnailSrc: "assets/jpg/video-thumbnail-fallback.jpg",
unsupportedBtihMessage: "Unsupported magnet link"
},
state: { loadedThumbnails },
// Callbacks can be wired if we want full interactivity (play, etc)
ensureGlobalMoreMenuHandlers: () => {},
onRequestCloseAllMenus: () => {}
});
// Wire up Play
videoCard.onPlay = ({ event: domEvent }) => {
// We can reuse the global playback handler if available
if (app?.playVideoByEventId) {
app.playVideoByEventId(video.id, {
url: video.url,
magnet: video.magnet,
title: video.title
});
} else if (app?.playVideoWithFallback) {
app.playVideoWithFallback({
url: video.url,
magnet: video.magnet
});
}
};
videoCard.onAuthorNavigate = ({ pubkey }) => {
if (app?.goToProfile) {
app.goToProfile(pubkey || video.pubkey);
}
};
const el = videoCard.getRoot();
if (el) fragment.appendChild(el);
}
container.appendChild(fragment);
}
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) {
const app = getApp();
if (app && typeof app.getProfileCacheEntry === 'function') {
const seenPubkeys = new Set();
for (const video of nostrClient.activeMap.values()) {
if (!video || !video.pubkey || seenPubkeys.has(video.pubkey)) continue;
seenPubkeys.add(video.pubkey);
const entry = app.getProfileCacheEntry(video.pubkey);
if (entry?.profile) {
const name = entry.profile.name || entry.profile.display_name || "";
const about = entry.profile.about || "";
const nip05 = entry.profile.nip05 || "";
const lightning = entry.profile.lightningAddress || "";
if (name.toLowerCase().includes(normalizedQuery) ||
about.toLowerCase().includes(normalizedQuery) ||
nip05.toLowerCase().includes(normalizedQuery) ||
lightning.toLowerCase().includes(normalizedQuery)) {
uniqueProfiles.set(video.pubkey, {
pubkey: video.pubkey,
metadata: entry.profile,
created_at: entry.event?.created_at || 0
});
}
} else if (video.authorName && video.authorName.toLowerCase().includes(normalizedQuery)) {
// Fallback to video metadata if profile cache missing
uniqueProfiles.set(video.pubkey, {
pubkey: video.pubkey,
metadata: {
name: video.authorName,
picture: video.authorPicture,
nip05: video.authorNip05
},
created_at: 0
});
}
}
}
}
if (currentSearchToken !== token) return [];
// 2. Relay Search (NIP-50)
if (nostrClient && nostrClient.pool) {
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: [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]);
if (currentSearchToken === token) {
for (const event of events) {
try {
const metadata = JSON.parse(event.content);
// Merge/Overwrite with newer
const existing = uniqueProfiles.get(event.pubkey);
if (!existing || existing.created_at < event.created_at) {
uniqueProfiles.set(event.pubkey, {
pubkey: event.pubkey,
metadata,
created_at: event.created_at
});
}
} catch (e) {
// ignore invalid content
}
}
}
} catch (e) {
devLogger.warn("Profile relay search error", e);
}
}
}
if (currentSearchToken !== token) return [];
// Convert map to array and sort by something relevant (maybe creation time or exact match?)
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 = {}) {
if (!nostrClient) return [];
const isHashtag = query.startsWith("#");
const term = isHashtag ? query.slice(1) : query;
const lowerTerm = term.toLowerCase();
const app = getApp();
const nostrService = app?.nostrService;
const filterOptions = {
blacklistedEventIds:
app?.blacklistedEventIds instanceof Set ? new Set(app.blacklistedEventIds) : new Set(),
isAuthorBlocked:
typeof app?.isAuthorBlocked === "function" ? (pubkey) => app.isAuthorBlocked(pubkey) : () => false
};
const applyAccessFilters = (videos) =>
nostrService?.filterVideos ? nostrService.filterVideos(videos, filterOptions) : videos;
const authorSet = new Set(
Array.isArray(filters?.authorPubkeys)
? filters.authorPubkeys.map((author) => author.toLowerCase())
: []
);
const tagSet = new Set(
Array.isArray(filters?.tags) ? filters.tags.map((tag) => tag.toLowerCase()) : []
);
const afterDate = Number.isFinite(filters?.dateRange?.after) ? filters.dateRange.after : null;
const beforeDate = Number.isFinite(filters?.dateRange?.before) ? filters.dateRange.before : null;
const minDuration = Number.isFinite(filters?.duration?.minSeconds)
? filters.duration.minSeconds
: null;
const maxDuration = Number.isFinite(filters?.duration?.maxSeconds)
? filters.duration.maxSeconds
: 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 matchesRelayFilters = (video) => {
if (!video) return false;
if (authorSet.size > 0) {
const pubkey = typeof video.pubkey === "string" ? video.pubkey.toLowerCase() : "";
if (!pubkey || !authorSet.has(pubkey)) return false;
}
if (tagSet.size > 0) {
const tags = Array.isArray(video.tags) ? video.tags : [];
const hasTag = tags.some((tag) => {
if (Array.isArray(tag)) {
return tag[0] === "t" && tagSet.has(String(tag[1] || "").toLowerCase());
}
return tagSet.has(String(tag || "").toLowerCase());
});
if (!hasTag) return false;
}
if (Number.isFinite(afterDate) && (!Number.isFinite(video.created_at) || video.created_at < afterDate)) {
return false;
}
if (Number.isFinite(beforeDate) && (!Number.isFinite(video.created_at) || video.created_at > beforeDate)) {
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);
if (!Number.isFinite(duration)) return false;
if (Number.isFinite(minDuration) && duration < minDuration) return false;
if (Number.isFinite(maxDuration) && duration > maxDuration) return false;
}
if (ALLOW_NSFW_CONTENT !== true && video.isNsfw === true) {
return false;
}
if (ALLOW_NSFW_CONTENT === true) {
if ((nsfwFilter === "true" || nsfwFilter === "only") && video.isNsfw !== true) {
return false;
}
if (nsfwFilter === "false" && video.isNsfw === true) {
return false;
}
}
return true;
};
// 1. Local Search (Cache)
const localMatches = [];
if (nostrClient.activeMap instanceof Map) {
for (const video of nostrClient.activeMap.values()) {
if (!video || video.deleted) continue;
let matches = false;
if (isHashtag) {
// Check tags for 't'
if (Array.isArray(video.tags)) {
matches = video.tags.some(t => Array.isArray(t) && t[0] === "t" && t[1].toLowerCase() === lowerTerm);
}
} else {
// Check title/description
const title = video.title ? video.title.toLowerCase() : "";
const desc = video.description ? video.description.toLowerCase() : "";
if (title.includes(lowerTerm) || desc.includes(lowerTerm)) {
matches = true;
}
}
if (matches) {
localMatches.push(video);
}
}
}
if (currentSearchToken !== token) return [];
const filteredLocalMatches = applyAccessFilters(localMatches).filter(
(video) => matchesRelayFilters(video) && matchesUnsupportedFilters(video)
);
// 2. Relay Search
let relayVideos = [];
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: [Number.isFinite(filters?.kind) ? filters.kind : 30078],
limit: 50
};
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]);
if (currentSearchToken === token) {
const { convertEventToVideo } = await import("./nostr/index.js");
relayVideos = events.map(evt => {
const vid = convertEventToVideo(evt);
if (vid.invalid) return null;
return vid;
}).filter(Boolean);
}
} catch (e) {
devLogger.warn("Video search error", e);
// Continue with local matches if relay search fails
}
}
if (currentSearchToken !== token) return [];
const filteredRelayVideos = applyAccessFilters(relayVideos).filter(
(video) => matchesRelayFilters(video) && matchesUnsupportedFilters(video)
);
// Deduplicate by ID
const unique = new Map();
// Add local matches first
for (const v of filteredLocalMatches) {
if (!unique.has(v.id)) unique.set(v.id, v);
}
// Add relay matches (overwriting local if needed, or ignoring duplicates)
for (const v of filteredRelayVideos) {
if (!unique.has(v.id)) unique.set(v.id, v);
}
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);
}