mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Merge pull request #1599 from PR0M3TH3AN/codex/add-sort-options-to-filter-schema-and-ui
Add search sort options and stable search ordering
This commit is contained in:
3 files changed
+234
-12
No files matched your search
@@ -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,
|
||||
@@ -258,6 +269,15 @@ export function parseFilterQuery(inputString = "") {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "sort": {
|
||||
const normalizedValue = value.toLowerCase();
|
||||
if (!SORT_VALUES.has(normalizedValue)) {
|
||||
errors.push({ token, message: "Sort filter is invalid." });
|
||||
break;
|
||||
}
|
||||
filters.sort = normalizedValue;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
errors.push({ token, message: `Unknown filter "${key}".` });
|
||||
}
|
||||
@@ -327,6 +347,10 @@ export function serializeFiltersToQuery(filters = DEFAULT_FILTERS) {
|
||||
tokens.push(`nsfw:${filters.nsfw}`);
|
||||
}
|
||||
|
||||
if (filters.sort && filters.sort !== DEFAULT_FILTERS.sort) {
|
||||
tokens.push(`sort:${filters.sort}`);
|
||||
}
|
||||
|
||||
return tokens.join(" ");
|
||||
}
|
||||
|
||||
|
||||
+195
-2
@@ -11,6 +11,7 @@ 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,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
setSearchFilterState,
|
||||
syncSearchFilterStateFromHash,
|
||||
} from "./search/searchFilterState.js";
|
||||
import { DEFAULT_FILTERS, SORT_OPTIONS } from "./search/searchFilters.js";
|
||||
|
||||
function getApp() {
|
||||
return getApplication();
|
||||
@@ -194,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 || {};
|
||||
@@ -352,6 +453,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;
|
||||
@@ -828,6 +940,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,5 @@
|
||||
import createPopover from "../overlay/popoverEngine.js";
|
||||
import { DEFAULT_FILTERS } from "../../search/searchFilters.js";
|
||||
import { DEFAULT_FILTERS, SORT_OPTIONS } 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\"])";
|
||||
@@ -276,14 +276,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);
|
||||
@@ -459,7 +461,7 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
|
||||
followedSwitch.classList.remove("is-on");
|
||||
}
|
||||
if (sortSelect) {
|
||||
sortSelect.selectedIndex = 0;
|
||||
sortSelect.value = safeFilters.sort || DEFAULT_FILTERS.sort;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -510,6 +512,9 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) {
|
||||
if (nsfwSwitch?.getAttribute("aria-checked") === "true") {
|
||||
filters.nsfw = "true";
|
||||
}
|
||||
if (sortSelect?.value) {
|
||||
filters.sort = sortSelect.value;
|
||||
}
|
||||
return filters;
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user