mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-01 18:32:27 +00:00
Implement user and video search functionality
- Updated `js/index.js` to handle search input: - Redirects valid npubs to the channel profile. - Redirects other queries to the new search view. - Created `views/search.html` to display search results. - Created `js/searchView.js` to perform NIP-50 searches for profiles (Kind 0) and videos (Kind 30078). - Includes specific handling for hashtag searches (using `#t` filter). - Updated `js/viewManager.js` to register the new search view.
This commit is contained in:
1 parent
827e99be6e
commit
2515ed9e8e
7 files changed
+905
-1443
No files matched your search
+22
-11
@@ -685,23 +685,34 @@ async function bootstrapInterface() {
|
||||
try {
|
||||
await applicationReadyPromise;
|
||||
} catch (error) {
|
||||
userLogger.info("Search function is coming soon.");
|
||||
// Continue even if app fails full initialization, if possible.
|
||||
}
|
||||
|
||||
const input = document.getElementById("headerSearchInput");
|
||||
const query = input && input.value ? input.value.trim() : "";
|
||||
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (application && typeof application.showStatus === "function") {
|
||||
try {
|
||||
application.showStatus("Search function is coming soon.", {
|
||||
autoHideMs: 3500,
|
||||
showSpinner: false,
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
// fall through to logger fallback
|
||||
// Check if it's an npub
|
||||
try {
|
||||
if (
|
||||
window.NostrTools &&
|
||||
window.NostrTools.nip19 &&
|
||||
query.startsWith("npub1")
|
||||
) {
|
||||
const decoded = window.NostrTools.nip19.decode(query);
|
||||
if (decoded && decoded.type === "npub") {
|
||||
setHashView(`channel-profile&npub=${encodeURIComponent(query)}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Not a valid npub, proceed to search
|
||||
}
|
||||
|
||||
userLogger.info("Search function is coming soon.");
|
||||
setHashView(`search&q=${encodeURIComponent(query)}`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
// 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 = `<p class="text-sm text-muted col-span-full">No matching channels found.</p>`;
|
||||
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 = `
|
||||
<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 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 = `<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).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).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>`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
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 || !nostrClient.pool) return [];
|
||||
|
||||
const isHashtag = query.startsWith("#");
|
||||
const term = isHashtag ? query.slice(1) : query;
|
||||
|
||||
const filter = {
|
||||
kinds: [30078],
|
||||
limit: 50
|
||||
};
|
||||
|
||||
if (isHashtag) {
|
||||
filter["#t"] = [term];
|
||||
} else {
|
||||
filter.search = term;
|
||||
}
|
||||
|
||||
const relays = nostrClient.relays || [];
|
||||
if (relays.length === 0) return [];
|
||||
|
||||
let events = [];
|
||||
try {
|
||||
events = await nostrClient.pool.list(relays, [filter]);
|
||||
} catch (e) {
|
||||
devLogger.warn("Video search error", e);
|
||||
return [];
|
||||
}
|
||||
|
||||
if (currentSearchToken !== token) return [];
|
||||
|
||||
const { convertEventToVideo } = await import("./nostr/index.js");
|
||||
|
||||
const videos = events.map(evt => {
|
||||
const vid = convertEventToVideo(evt);
|
||||
if (vid.invalid) return null;
|
||||
return vid;
|
||||
}).filter(Boolean);
|
||||
|
||||
// Deduplicate by ID (or root)
|
||||
const unique = new Map();
|
||||
for (const v of videos) {
|
||||
if (!unique.has(v.id)) {
|
||||
unique.set(v.id, v);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(unique.values());
|
||||
}
|
||||
@@ -135,4 +135,14 @@ export const viewInitRegistry = {
|
||||
// Call the initialization function from channelProfile.js
|
||||
initChannelProfileView();
|
||||
},
|
||||
search: async () => {
|
||||
try {
|
||||
const module = await import("./searchView.js");
|
||||
if (typeof module.initSearchView === "function") {
|
||||
await module.initSearchView();
|
||||
}
|
||||
} catch (error) {
|
||||
userLogger.error("Failed to initialize search view:", error);
|
||||
}
|
||||
},
|
||||
};
|
||||
+466
-1432
File diff suppressed because it is too large.
Load diff
Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
@@ -0,0 +1,40 @@
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
|
||||
def test_search_functionality():
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page()
|
||||
|
||||
# Navigate to the home page
|
||||
page.goto("http://localhost:3000/index.html")
|
||||
|
||||
# Verify search input exists
|
||||
search_input = page.locator("#headerSearchInput")
|
||||
expect(search_input).to_be_visible()
|
||||
|
||||
# Type a query
|
||||
search_input.fill("test")
|
||||
search_input.press("Enter")
|
||||
|
||||
# Wait for hash change and search results view
|
||||
page.wait_for_timeout(2000) # Give it a moment to load view
|
||||
|
||||
# Verify the hash updated
|
||||
assert "#view=search&q=test" in page.url
|
||||
|
||||
# Verify the search title updated
|
||||
title_locator = page.locator("#searchTitle")
|
||||
expect(title_locator).to_be_visible()
|
||||
expect(title_locator).to_have_text('Search Results for "test"')
|
||||
|
||||
# Verify sections exist
|
||||
expect(page.locator("#searchChannelList")).to_be_visible()
|
||||
expect(page.locator("#searchVideoList")).to_be_visible()
|
||||
|
||||
# Take a screenshot
|
||||
page.screenshot(path="verification/search_view.png", full_page=True)
|
||||
|
||||
browser.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_search_functionality()
|
||||
@@ -0,0 +1,34 @@
|
||||
<div class="fade-in bv-stack">
|
||||
<header class="mb-4">
|
||||
<h1
|
||||
id="searchTitle"
|
||||
class="text-2xl font-bold tracking-tight text-text-strong"
|
||||
>
|
||||
Search Results
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<section class="bv-stack">
|
||||
<h2 class="text-lg font-semibold uppercase tracking-wide text-muted-strong">
|
||||
Channels
|
||||
</h2>
|
||||
<div
|
||||
id="searchChannelList"
|
||||
class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
>
|
||||
<!-- Channel cards injected here -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bv-stack mt-8">
|
||||
<h2 class="text-lg font-semibold uppercase tracking-wide text-muted-strong">
|
||||
Videos
|
||||
</h2>
|
||||
<div
|
||||
id="searchVideoList"
|
||||
class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
>
|
||||
<!-- Video cards injected here -->
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
Reference in new issue
Block a user