// 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"; function getApp() { return getApplication(); } const FALLBACK_AVATAR = "assets/svg/default-profile.svg"; function renderProfileCards(profiles, container) { if (!container) return; container.innerHTML = ""; if (!profiles || profiles.length === 0) { container.innerHTML = `

No matching channels found.

`; return; } 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 = `
${escapeHTML(name)}

${escapeHTML(name)}

${escapeHTML(shortNpub)}

`; fragment.appendChild(card); } container.appendChild(fragment); } let currentSearchToken = 0; export async function initSearchView() { const hashParams = new URLSearchParams(window.location.hash.split("?")[1] || window.location.hash.slice(1)); // The hash format is #view=search&q=... // URLSearchParams handles 'view=search&q=...' correctly if we pass the string after # const query = hashParams.get("q") || ""; const titleEl = document.getElementById("searchTitle"); if (titleEl) { titleEl.textContent = query ? `Search Results for "${query}"` : "Search Results"; } const channelList = document.getElementById("searchChannelList"); const videoList = document.getElementById("searchVideoList"); if (channelList) { channelList.innerHTML = `

Searching channels...

`; } if (videoList) { videoList.innerHTML = `

Searching videos...

`; } if (!query) { if (channelList) channelList.innerHTML = `

Please enter a search term.

`; if (videoList) videoList.innerHTML = `

Please enter a search term.

`; return; } const searchToken = ++currentSearchToken; // 1. Search Profiles performProfileSearch(query, searchToken).then(profiles => { if (searchToken !== currentSearchToken) return; renderProfileCards(profiles, channelList); }).catch(err => { devLogger.warn("[Search] Profile search failed", err); if (channelList && searchToken === currentSearchToken) { channelList.innerHTML = `

Failed to load channels.

`; } }); // 2. Search Videos performVideoSearch(query, searchToken).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 = `

Failed to load videos.

`; } }); } async function renderSearchVideos(videos, container, app) { if (!container) return; container.innerHTML = ""; if (!videos || videos.length === 0) { container.innerHTML = `

No matching videos found.

`; 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) { if (!nostrClient || !nostrClient.pool) return []; const filter = { kinds: [0], search: query, limit: 20 }; // If query looks like NIP-05 (user@domain), we could try to resolve it directly? // For now, reliance on NIP-50 `search` is safer for broad queries. // We should also search our local cache? // The requirement says "search nostr relays". const relays = nostrClient.relays || []; if (relays.length === 0) return []; let events = []; try { events = await nostrClient.pool.list(relays, [filter]); } catch (e) { devLogger.warn("Profile search error", e); return []; } if (currentSearchToken !== token) return []; const profiles = events.map(event => { try { const metadata = JSON.parse(event.content); return { pubkey: event.pubkey, metadata, created_at: event.created_at }; } catch (e) { return null; } }).filter(Boolean); // Deduplicate by pubkey, keeping newest const map = new Map(); for (const p of profiles) { if (!map.has(p.pubkey) || map.get(p.pubkey).created_at < p.created_at) { map.set(p.pubkey, p); } } return Array.from(map.values()); } async function performVideoSearch(query, token) { if (!nostrClient) return []; const isHashtag = query.startsWith("#"); const term = isHashtag ? query.slice(1) : query; const lowerTerm = term.toLowerCase(); // 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 []; // 2. Relay Search let relayVideos = []; const relays = nostrClient.relays || []; if (relays.length > 0 && nostrClient.pool) { const filter = { kinds: [30078], limit: 50 }; if (isHashtag) { filter["#t"] = [term]; } else { 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 []; // Deduplicate by ID const unique = new Map(); // Add local matches first for (const v of localMatches) { if (!unique.has(v.id)) unique.set(v.id, v); } // Add relay matches (overwriting local if needed, or ignoring duplicates) for (const v of relayVideos) { 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)); }