From 3df398d7ad92b2f82296e9a7bb0de14f7bc697aa Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:59:56 -0400 Subject: [PATCH] [upload] Suggest one-tap hashtag chips from the user's past videos (#45) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upload modal's Hashtags section now shows chips of the user's most-used past hashtags ("Reuse a tag from your videos:"). Clicking one adds it to the NIP-71 't' repeater; already-added chips dim + disable; the row is hidden for users with no past tags. - js/utils/hashtagSuggestions.js: pure rankHashtagsByFrequency(videos) — count = number of the user's videos using each tag, normalized (case + '#') and deduped per video, ranked by count then alphabetically. Reads video.nip71.hashtags, falls back to raw event 't' tags minus bitvid's fixed t=video marker. - app.getUserHashtagSuggestions() sources the user's own videos via nostrService.getActiveVideosByAuthors([pubkey]) and ranks them (top 12), injected into the modal as getHashtagSuggestions. - UploadModal renders the chips on open and wires clicks through the existing Nip71FormManager (addRepeaterEntry + setFieldValue, dedup via collectRepeaterValues). Tests: tests/hashtag-suggestions.test.mjs (5 — per-video counting, normalization, marker exclusion, tie-break + limit, empty input). Lint + build green. Co-Authored-By: Claude Opus 4.8 --- components/upload-modal.html | 4 + js/app.js | 24 ++++++ js/ui/components/UploadModal.js | 118 +++++++++++++++++++++++++++++ js/ui/initUploadModal.js | 6 ++ js/utils/hashtagSuggestions.js | 69 +++++++++++++++++ tests/hashtag-suggestions.test.mjs | 86 +++++++++++++++++++++ todo/TODO_2026-06-20_pre-launch.md | 16 ++-- 7 files changed, 318 insertions(+), 5 deletions(-) create mode 100644 js/utils/hashtagSuggestions.js create mode 100644 tests/hashtag-suggestions.test.mjs diff --git a/components/upload-modal.html b/components/upload-modal.html index a955c113..a1603202 100644 --- a/components/upload-modal.html +++ b/components/upload-modal.html @@ -249,6 +249,10 @@
+
{}; this.getCurrentPubkey = typeof getCurrentPubkey === "function" ? getCurrentPubkey : null; + this.getHashtagSuggestions = + typeof getHashtagSuggestions === "function" ? getHashtagSuggestions : null; this.safeEncodeNpub = typeof safeEncodeNpub === "function" ? safeEncodeNpub : () => ""; // Shared storage+torrent upload core (also used by the Edit modal). @@ -216,6 +219,8 @@ export class UploadModal { this.submitButton = $("#btn-submit"); this.submitStatus = $("#submit-status"); this.closeButton = $("#closeUploadModal"); + this.hashtagSuggestionsWrap = $("#hashtag-suggestions-wrap"); + this.hashtagSuggestionsList = $("#hashtag-suggestions"); // Mode Switchers this.modeButtons = { @@ -759,6 +764,117 @@ export class UploadModal { } } + // --- Hashtag suggestion chips (TODO #45) --- + + // The hashtags currently entered in the "t" repeater (normalized). + getSelectedHashtags() { + if (!this.nip71FormManager) { + return []; + } + try { + return this.nip71FormManager + .collectRepeaterValues("main", "t", (entry) => + this.nip71FormManager.getFieldValue(entry, "value"), + ) + .map((value) => this.nip71FormManager.sanitizeHashtagValue(value)) + .filter(Boolean); + } catch (error) { + return []; + } + } + + // Add a hashtag to the "t" repeater (skips duplicates). Returns true if added. + addHashtagValue(rawValue) { + if (!this.nip71FormManager) { + return false; + } + const normalized = this.nip71FormManager.sanitizeHashtagValue(rawValue); + if (!normalized) { + return false; + } + if (new Set(this.getSelectedHashtags()).has(normalized)) { + return false; + } + const entry = this.nip71FormManager.addRepeaterEntry("main", "t"); + if (!entry) { + return false; + } + this.nip71FormManager.setFieldValue(entry, "value", normalized); + return true; + } + + // Render one-tap chips for the user's most-used past hashtags. Hidden when the + // user has none (new/first upload). + renderHashtagSuggestions() { + const list = this.hashtagSuggestionsList; + const wrap = this.hashtagSuggestionsWrap; + if (!(list instanceof HTMLElement)) { + return; + } + + let suggestions = []; + if (typeof this.getHashtagSuggestions === "function") { + try { + suggestions = this.getHashtagSuggestions({ limit: 12 }) || []; + } catch (error) { + suggestions = []; + } + } + + list.textContent = ""; + const usable = Array.isArray(suggestions) ? suggestions : []; + if (!usable.length) { + if (wrap instanceof HTMLElement) { + wrap.classList.add("hidden"); + } + return; + } + + for (const item of usable) { + const tag = this.nip71FormManager + ? this.nip71FormManager.sanitizeHashtagValue(item?.tag) + : typeof item?.tag === "string" + ? item.tag + : ""; + if (!tag) { + continue; + } + const chip = document.createElement("button"); + chip.type = "button"; + chip.className = + "btn-ghost px-3 py-1 text-xs bg-surface border border-border/50 rounded-full"; + chip.dataset.tag = tag; + chip.textContent = `#${tag}`; + chip.addEventListener("click", () => { + this.addHashtagValue(tag); + this.refreshHashtagSuggestionStates(); + }); + list.appendChild(chip); + } + + if (wrap instanceof HTMLElement) { + wrap.classList.remove("hidden"); + } + this.refreshHashtagSuggestionStates(); + } + + // Dim + disable chips whose tag is already in the current tag list. + refreshHashtagSuggestionStates() { + const list = this.hashtagSuggestionsList; + if (!(list instanceof HTMLElement)) { + return; + } + const selected = new Set(this.getSelectedHashtags()); + list.querySelectorAll("[data-tag]").forEach((chip) => { + const isSelected = selected.has(chip.dataset.tag); + chip.classList.toggle("opacity-40", isSelected); + chip.setAttribute("aria-pressed", isSelected ? "true" : "false"); + if ("disabled" in chip) { + chip.disabled = isSelected; + } + }); + } + async loadFromStorage() { if (!this.storageService) return; const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null; @@ -1274,6 +1390,8 @@ export class UploadModal { }); } + this.renderHashtagSuggestions(); + this.modalAccessibility?.activate({ triggerElement }); } diff --git a/js/ui/initUploadModal.js b/js/ui/initUploadModal.js index 7309f17f..06ccc9e8 100644 --- a/js/ui/initUploadModal.js +++ b/js/ui/initUploadModal.js @@ -48,6 +48,12 @@ export function initUploadModal({ showSuccess, getCurrentPubkey, safeEncodeNpub, + // Frequency-ranked hashtags from the user's own past uploads → one-tap + // suggestion chips in the upload modal (TODO #45). + getHashtagSuggestions: (options) => + typeof app?.getUserHashtagSuggestions === "function" + ? app.getUserHashtagSuggestions(options) + : [], eventTarget: events, container, onRequestStorageSettings: () => { diff --git a/js/utils/hashtagSuggestions.js b/js/utils/hashtagSuggestions.js new file mode 100644 index 00000000..8006eed0 --- /dev/null +++ b/js/utils/hashtagSuggestions.js @@ -0,0 +1,69 @@ +// Rank a user's previously-used hashtags by how many of their videos use each one, +// for the upload modal's one-tap suggestion chips (TODO #45). Pure + side-effect +// free so the ranking is unit-testable without the app shell. +// +// Input: an array of the user's own video objects. Each may carry hashtags as +// `nip71.hashtags` / `nip71.t` (parsed NIP-71 metadata) or as raw event `t` tags. +// Output: [{ tag, count }] sorted by count desc then alphabetically, normalized + +// deduped (a tag is counted once per video, so `count` = number of videos using it). + +function normalizeTag(value) { + if (typeof value !== "string") { + return ""; + } + return value.trim().replace(/^#+/, "").toLowerCase(); +} + +export function extractVideoHashtags(video) { + if (!video || typeof video !== "object") { + return []; + } + const nip71 = video.nip71; + if (nip71 && typeof nip71 === "object") { + if (Array.isArray(nip71.hashtags)) { + return nip71.hashtags; + } + if (Array.isArray(nip71.t)) { + return nip71.t; + } + } + // Fallback: raw event `t` tags, minus bitvid's fixed "video" marker tag. + if (Array.isArray(video.tags)) { + return video.tags + .filter( + (tag) => + Array.isArray(tag) && + tag[0] === "t" && + typeof tag[1] === "string" && + tag[1] && + tag[1] !== "video", + ) + .map((tag) => tag[1]); + } + return []; +} + +export function rankHashtagsByFrequency(videos, { limit } = {}) { + const counts = new Map(); + const list = Array.isArray(videos) ? videos : []; + + for (const video of list) { + const seenInThisVideo = new Set(); + for (const raw of extractVideoHashtags(video)) { + const tag = normalizeTag(raw); + if (!tag || seenInThisVideo.has(tag)) { + continue; + } + seenInThisVideo.add(tag); + counts.set(tag, (counts.get(tag) || 0) + 1); + } + } + + const ranked = Array.from(counts.entries()) + .map(([tag, count]) => ({ tag, count })) + .sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag)); + + return Number.isFinite(limit) && limit > 0 ? ranked.slice(0, limit) : ranked; +} + +export default rankHashtagsByFrequency; diff --git a/tests/hashtag-suggestions.test.mjs b/tests/hashtag-suggestions.test.mjs new file mode 100644 index 00000000..5f4ba46c --- /dev/null +++ b/tests/hashtag-suggestions.test.mjs @@ -0,0 +1,86 @@ +// Upload-modal hashtag suggestions (TODO #45): rank a user's own past hashtags by +// how many of their videos use each, for one-tap reuse chips. +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-hashtag-suggestions-rank +// given: "a user's video objects with nip71.hashtags / nip71.t / raw t tags" +// when: "rankHashtagsByFrequency runs" +// then: "tags are normalized, deduped per video, ranked by video-count then name" +// observable_outcomes: +// - "count = number of videos using the tag (once per video)" +// - "case/#-prefix normalized and merged" +// - "bitvid's fixed t=video marker excluded from raw tags (but a real 'video' hashtag counts)" +// - "ties broken alphabetically; limit respected; empty input -> []" +// determinism_controls: +// - "pure function; explicit inputs" +// anti_cheat_rationale: +// prevents: ["hard-coded return value"] +// relaxation: +// did_relax_any_assertion: false + +import test from "node:test"; +import { strict as assert } from "node:assert"; +import { + rankHashtagsByFrequency, + extractVideoHashtags, +} from "../js/utils/hashtagSuggestions.js"; + +test("counts videos-per-tag, normalizing case and the # prefix", () => { + const ranked = rankHashtagsByFrequency([ + { nip71: { hashtags: ["Nostr", "Bitcoin"] } }, + { nip71: { hashtags: ["nostr", "#bitcoin"] } }, + { nip71: { hashtags: ["NOSTR"] } }, + ]); + assert.deepEqual(ranked, [ + { tag: "nostr", count: 3 }, + { tag: "bitcoin", count: 2 }, + ]); +}); + +test("a tag repeated within ONE video counts once for that video", () => { + const ranked = rankHashtagsByFrequency([ + { nip71: { hashtags: ["nostr", "nostr", "#Nostr"] } }, + ]); + assert.deepEqual(ranked, [{ tag: "nostr", count: 1 }]); +}); + +test("falls back to raw event t tags and excludes the fixed 'video' marker", () => { + assert.deepEqual( + extractVideoHashtags({ + tags: [ + ["t", "video"], + ["t", "bitcoin"], + ["d", "abc"], + ["t", ""], + ], + }), + ["bitcoin"], + ); + // But a genuine nip71 "video" hashtag is NOT stripped (only the raw marker is). + const ranked = rankHashtagsByFrequency([{ nip71: { hashtags: ["video"] } }]); + assert.deepEqual(ranked, [{ tag: "video", count: 1 }]); +}); + +test("ties break alphabetically and limit is respected", () => { + const ranked = rankHashtagsByFrequency( + [ + { nip71: { hashtags: ["zebra", "apple", "mango"] } }, + { nip71: { hashtags: ["zebra", "apple"] } }, + { nip71: { hashtags: ["apple"] } }, + ], + { limit: 2 }, + ); + // apple:3, zebra:2, mango:1 -> top 2 + assert.deepEqual(ranked, [ + { tag: "apple", count: 3 }, + { tag: "zebra", count: 2 }, + ]); +}); + +test("empty / malformed input yields an empty list", () => { + assert.deepEqual(rankHashtagsByFrequency([]), []); + assert.deepEqual(rankHashtagsByFrequency(null), []); + assert.deepEqual(rankHashtagsByFrequency([{}, { nip71: {} }, { tags: "nope" }]), []); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index 0358e8b4..d4ac2ba3 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -572,11 +572,17 @@ toward freshness and looked identical. Gave each a structural identity: connection model. ### 45. Upload modal: suggest tags from the user's previous videos (UX) -- [ ] In the upload modal, let the user **pick tags reused from their other videos** — - surface a set of their **commonly-used tags** (frequency-ranked across their past - uploads) as one-tap chips, plus their less-common individual tags. Source from the - user's own video events' `t` tags (My Videos / their kind-30078+NIP-71 posts). - Rank by usage count; clicking a chip adds it to the new video's tags. +- [x] **DONE 2026-07-01.** The Hashtags section of the upload modal now shows one-tap + chips of the user's most-used past hashtags ("Reuse a tag from your videos:"). + Source: `app.getUserHashtagSuggestions()` reads the user's own videos + (`nostrService.getActiveVideosByAuthors([pubkey])`) and ranks their hashtags via + `js/utils/hashtagSuggestions.js` `rankHashtagsByFrequency` — count = number of + videos using each tag, normalized (case + `#`) and deduped per video, ties broken + alphabetically (top 12). Tags come from `video.nip71.hashtags` (falls back to raw + `t` tags minus bitvid's fixed `t=video` marker). Clicking a chip adds it to the + NIP-71 `t` repeater (skips duplicates); already-added chips dim + disable. Hidden + for users with no past tags. Rendered on modal open. Tests: + `tests/hashtag-suggestions.test.mjs` (5, pure ranking). Lint + build green. ### 46. Feed auto-refreshes after posting a video (UX) - [x] **DONE 2026-06-30.** `publishVideoNote` now (1) **optimistically injects** the