mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Add suggested facet counts to search filters
This commit is contained in:
1 parent
757c00d5c9
commit
d378ee2be6
3 files changed
+324
-1
No files matched your search
@@ -0,0 +1,40 @@
|
||||
const listeners = new Set();
|
||||
|
||||
const cloneFacetList = (list) =>
|
||||
Array.isArray(list) ? list.map((entry) => ({ ...entry })) : [];
|
||||
|
||||
let state = {
|
||||
tags: [],
|
||||
authors: [],
|
||||
relays: [],
|
||||
};
|
||||
|
||||
function notify() {
|
||||
const snapshot = getSearchFacetCounts();
|
||||
listeners.forEach((listener) => listener(snapshot));
|
||||
}
|
||||
|
||||
export function getSearchFacetCounts() {
|
||||
return {
|
||||
tags: cloneFacetList(state.tags),
|
||||
authors: cloneFacetList(state.authors),
|
||||
relays: cloneFacetList(state.relays),
|
||||
};
|
||||
}
|
||||
|
||||
export function setSearchFacetCounts(nextState = {}) {
|
||||
state = {
|
||||
tags: cloneFacetList(nextState.tags),
|
||||
authors: cloneFacetList(nextState.authors),
|
||||
relays: cloneFacetList(nextState.relays),
|
||||
};
|
||||
notify();
|
||||
}
|
||||
|
||||
export function subscribeSearchFacetCounts(listener) {
|
||||
if (typeof listener !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
+182
-1
@@ -4,13 +4,14 @@ 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 { formatShortNpub, truncateMiddle } 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 { setSearchFacetCounts } from "./search/searchFacetState.js";
|
||||
import { subscribeToVideoViewCount, unsubscribeFromVideoViewCount } from "./viewCounter.js";
|
||||
import {
|
||||
buildSearchHashFromState,
|
||||
@@ -26,6 +27,177 @@ function getApp() {
|
||||
}
|
||||
|
||||
const FALLBACK_AVATAR = "assets/svg/default-profile.svg";
|
||||
const EMPTY_FACET_COUNTS = { tags: [], authors: [], relays: [] };
|
||||
const MAX_FACET_RESULTS = {
|
||||
tags: 8,
|
||||
authors: 6,
|
||||
relays: 6,
|
||||
};
|
||||
|
||||
const normalizeFacetText = (value) => {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
return value.trim();
|
||||
};
|
||||
|
||||
const resolveShortPubkeyLabel = (pubkey) => {
|
||||
const trimmed = normalizeFacetText(pubkey);
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
let npub = "";
|
||||
try {
|
||||
if (window.NostrTools && window.NostrTools.nip19) {
|
||||
npub = window.NostrTools.nip19.npubEncode(trimmed);
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
const formattedNpub = formatShortNpub(npub);
|
||||
if (formattedNpub) {
|
||||
return formattedNpub;
|
||||
}
|
||||
return truncateMiddle(trimmed, 18);
|
||||
};
|
||||
|
||||
const resolveAuthorFacetLabel = (video) => {
|
||||
const name = normalizeFacetText(video?.authorName);
|
||||
if (name) {
|
||||
return name;
|
||||
}
|
||||
const npub = normalizeFacetText(video?.authorNpub);
|
||||
if (npub) {
|
||||
return formatShortNpub(npub) || npub;
|
||||
}
|
||||
return resolveShortPubkeyLabel(video?.pubkey);
|
||||
};
|
||||
|
||||
const extractRelayHints = (event) => {
|
||||
if (!event || typeof event !== "object") {
|
||||
return [];
|
||||
}
|
||||
const relayCandidates = [];
|
||||
const relayCollections = [event.relays, event.seenOn, event.seen_on];
|
||||
relayCollections.forEach((collection) => {
|
||||
if (Array.isArray(collection)) {
|
||||
relayCandidates.push(...collection);
|
||||
}
|
||||
});
|
||||
if (typeof event.relay === "string") {
|
||||
relayCandidates.push(event.relay);
|
||||
}
|
||||
return Array.from(
|
||||
new Set(
|
||||
relayCandidates
|
||||
.map((value) => normalizeFacetText(String(value)))
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeTagFacetValue = (value) =>
|
||||
normalizeFacetText(value)
|
||||
.replace(/^#/, "")
|
||||
.toLowerCase();
|
||||
|
||||
const buildFacetEntries = (map, limit) => {
|
||||
if (!map.size) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(map.values())
|
||||
.sort((a, b) => (b.count - a.count) || a.label.localeCompare(b.label))
|
||||
.slice(0, limit);
|
||||
};
|
||||
|
||||
const buildSearchFacetCounts = (videos = []) => {
|
||||
const tagCounts = new Map();
|
||||
const authorCounts = new Map();
|
||||
const relayCounts = new Map();
|
||||
|
||||
videos.forEach((video) => {
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
|
||||
const authorKey = normalizeFacetText(video.pubkey || "");
|
||||
if (authorKey) {
|
||||
const label = resolveAuthorFacetLabel(video) || authorKey;
|
||||
const entry = authorCounts.get(authorKey) || {
|
||||
value: authorKey,
|
||||
label,
|
||||
count: 0,
|
||||
};
|
||||
entry.count += 1;
|
||||
if (!entry.label && label) {
|
||||
entry.label = label;
|
||||
}
|
||||
authorCounts.set(authorKey, entry);
|
||||
}
|
||||
|
||||
const tagSet = new Set();
|
||||
if (Array.isArray(video.tags)) {
|
||||
video.tags.forEach((tag) => {
|
||||
if (Array.isArray(tag)) {
|
||||
if (tag[0] === "t") {
|
||||
const normalized = normalizeTagFacetValue(tag[1] || "");
|
||||
if (normalized) {
|
||||
tagSet.add(normalized);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeTagFacetValue(tag);
|
||||
if (normalized) {
|
||||
tagSet.add(normalized);
|
||||
}
|
||||
});
|
||||
}
|
||||
tagSet.forEach((tag) => {
|
||||
const label = `#${tag}`;
|
||||
const entry = tagCounts.get(tag) || { value: tag, label, count: 0 };
|
||||
entry.count += 1;
|
||||
tagCounts.set(tag, entry);
|
||||
});
|
||||
|
||||
const relaySet = new Set();
|
||||
if (Array.isArray(video.relays)) {
|
||||
video.relays.forEach((relay) => {
|
||||
const normalized = normalizeFacetText(relay);
|
||||
if (normalized) {
|
||||
relaySet.add(normalized);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (Array.isArray(video.relayHints)) {
|
||||
video.relayHints.forEach((relay) => {
|
||||
const normalized = normalizeFacetText(relay);
|
||||
if (normalized) {
|
||||
relaySet.add(normalized);
|
||||
}
|
||||
});
|
||||
}
|
||||
const relayField = normalizeFacetText(video.relay || video.eventRelay || "");
|
||||
if (relayField) {
|
||||
relaySet.add(relayField);
|
||||
}
|
||||
relaySet.forEach((relay) => {
|
||||
const entry = relayCounts.get(relay) || {
|
||||
value: relay,
|
||||
label: relay,
|
||||
count: 0,
|
||||
};
|
||||
entry.count += 1;
|
||||
relayCounts.set(relay, entry);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
tags: buildFacetEntries(tagCounts, MAX_FACET_RESULTS.tags),
|
||||
authors: buildFacetEntries(authorCounts, MAX_FACET_RESULTS.authors),
|
||||
relays: buildFacetEntries(relayCounts, MAX_FACET_RESULTS.relays),
|
||||
};
|
||||
};
|
||||
|
||||
function renderProfileCards(profiles, container) {
|
||||
if (!container) return;
|
||||
@@ -146,6 +318,7 @@ export async function initSearchView() {
|
||||
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>`;
|
||||
setSearchFacetCounts(EMPTY_FACET_COUNTS);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -166,12 +339,16 @@ export async function initSearchView() {
|
||||
performVideoSearch(query, searchToken, parsedQuery.filters).then(videos => {
|
||||
if (searchToken !== currentSearchToken) return;
|
||||
const app = getApp();
|
||||
setSearchFacetCounts(buildSearchFacetCounts(videos));
|
||||
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>`;
|
||||
}
|
||||
if (searchToken === currentSearchToken) {
|
||||
setSearchFacetCounts(EMPTY_FACET_COUNTS);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -930,6 +1107,10 @@ async function performVideoSearch(query, token, filters = {}) {
|
||||
relayVideos = events.map(evt => {
|
||||
const vid = convertEventToVideo(evt);
|
||||
if (vid.invalid) return null;
|
||||
const relayHints = extractRelayHints(evt);
|
||||
if (relayHints.length) {
|
||||
return { ...vid, relayHints };
|
||||
}
|
||||
return vid;
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
parseFilterQuery,
|
||||
serializeFiltersToQuery,
|
||||
} from "../../search/searchFilters.js";
|
||||
import {
|
||||
getSearchFacetCounts,
|
||||
subscribeSearchFacetCounts,
|
||||
} from "../../search/searchFacetState.js";
|
||||
import { userLogger } from "../../utils/logger.js";
|
||||
|
||||
const FOCUSABLE_SELECTOR =
|
||||
@@ -217,6 +221,8 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
|
||||
}
|
||||
|
||||
let controlState = null;
|
||||
let suggestedFacetRenderer = null;
|
||||
let suggestedFacetUnsubscribe = null;
|
||||
|
||||
const render = ({ container, close }) => {
|
||||
const panel = createElement(doc, "div", {
|
||||
@@ -257,6 +263,82 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
|
||||
});
|
||||
panel.appendChild(parseMessage);
|
||||
|
||||
const suggestedSection = createElement(doc, "section", {
|
||||
className: "bv-stack bv-stack--tight",
|
||||
});
|
||||
const suggestedHeading = createElement(doc, "p", {
|
||||
className: "text-xs font-semibold uppercase tracking-wide text-muted",
|
||||
text: "Suggested facets",
|
||||
});
|
||||
const suggestedGroups = createElement(doc, "div", {
|
||||
className: "bv-stack bv-stack--tight",
|
||||
});
|
||||
const suggestedEmpty = createElement(doc, "p", {
|
||||
className: "text-xs text-muted",
|
||||
text: "Run a search to see suggested facets.",
|
||||
});
|
||||
suggestedSection.append(suggestedHeading, suggestedGroups, suggestedEmpty);
|
||||
panel.appendChild(suggestedSection);
|
||||
|
||||
const renderSuggestedFacets = (facets = {}) => {
|
||||
suggestedGroups.innerHTML = "";
|
||||
const tags = Array.isArray(facets.tags) ? facets.tags : [];
|
||||
const authors = Array.isArray(facets.authors) ? facets.authors : [];
|
||||
const relays = Array.isArray(facets.relays) ? facets.relays : [];
|
||||
|
||||
const addFacetGroup = (title, items = []) => {
|
||||
if (!items.length) {
|
||||
return;
|
||||
}
|
||||
const group = createElement(doc, "div", {
|
||||
className: "bv-stack bv-stack--tight",
|
||||
});
|
||||
group.appendChild(
|
||||
createElement(doc, "p", {
|
||||
className:
|
||||
"text-xs font-semibold uppercase tracking-wide text-muted",
|
||||
text: title,
|
||||
}),
|
||||
);
|
||||
const list = createElement(doc, "div", {
|
||||
className: "flex flex-wrap gap-2",
|
||||
});
|
||||
items.forEach((item) => {
|
||||
const label =
|
||||
typeof item?.label === "string" ? item.label : item?.value || "";
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
const count = Number.isFinite(item?.count)
|
||||
? Number(item.count)
|
||||
: 0;
|
||||
const chip = createElement(doc, "span", {
|
||||
className: "search-filter-chip",
|
||||
text: `${label} (${count})`,
|
||||
});
|
||||
list.appendChild(chip);
|
||||
});
|
||||
group.appendChild(list);
|
||||
suggestedGroups.appendChild(group);
|
||||
};
|
||||
|
||||
addFacetGroup("Tags", tags);
|
||||
addFacetGroup("Authors", authors);
|
||||
addFacetGroup("Relays", relays);
|
||||
|
||||
const hasSuggestions = Boolean(
|
||||
suggestedGroups.children && suggestedGroups.children.length > 0,
|
||||
);
|
||||
suggestedEmpty.classList.toggle("hidden", hasSuggestions);
|
||||
};
|
||||
|
||||
suggestedFacetRenderer = renderSuggestedFacets;
|
||||
const initialFacets =
|
||||
typeof options.getSuggestedFacets === "function"
|
||||
? options.getSuggestedFacets()
|
||||
: getSearchFacetCounts();
|
||||
renderSuggestedFacets(initialFacets);
|
||||
|
||||
const dateSection = createElement(doc, "div", {
|
||||
className: "bv-stack bv-stack--tight",
|
||||
});
|
||||
@@ -921,6 +1003,26 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
|
||||
restoreFocusOnClose: true,
|
||||
});
|
||||
|
||||
if (!suggestedFacetUnsubscribe) {
|
||||
suggestedFacetUnsubscribe = subscribeSearchFacetCounts((next) => {
|
||||
if (typeof suggestedFacetRenderer === "function") {
|
||||
suggestedFacetRenderer(next);
|
||||
}
|
||||
});
|
||||
}
|
||||
const originalDestroy = popover.destroy?.bind(popover);
|
||||
popover.destroy = (...args) => {
|
||||
if (suggestedFacetUnsubscribe) {
|
||||
suggestedFacetUnsubscribe();
|
||||
suggestedFacetUnsubscribe = null;
|
||||
}
|
||||
suggestedFacetRenderer = null;
|
||||
if (originalDestroy) {
|
||||
return originalDestroy(...args);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const syncFromQueryInput = (rawQuery) => {
|
||||
if (typeof rawQuery !== "string") {
|
||||
return { filters: DEFAULT_FILTERS, text: "", tokens: [], errors: [] };
|
||||
|
||||
Reference in new issue
Block a user