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:
thePR0M3TH3AN authored and GitHub committed 2026-01-23 12:59:16 -05:00
commit 9774c4e3eb
3 files changed
+234 -12

No files matched your search

+24
View File
@@ -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
View File
@@ -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);
}
+15 -10
View File
@@ -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;
};