mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
[upload] Suggest one-tap hashtag chips from the user's past videos (#45)
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 <[email protected]>
This commit is contained in:
1 parent
6bdf28fb30
commit
3df398d7ad
7 files changed
+318
-5
No files matched your search
@@ -249,6 +249,10 @@
|
||||
<!-- Hashtags -->
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium text-text">Hashtags</label>
|
||||
<div id="hashtag-suggestions-wrap" class="space-y-1 hidden">
|
||||
<p class="text-xs text-muted">Reuse a tag from your videos:</p>
|
||||
<div id="hashtag-suggestions" class="flex flex-wrap gap-2"></div>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-lg border border-border/70 bg-surface/30 p-3 space-y-3"
|
||||
data-nip71-repeater="t"
|
||||
|
||||
@@ -99,6 +99,7 @@ import {
|
||||
logoutSigner,
|
||||
} from "./nostrClientRegistry.js";
|
||||
import { resolveSignerCapabilities } from "./nostr/signerCapabilities.js";
|
||||
import { rankHashtagsByFrequency } from "./utils/hashtagSuggestions.js";
|
||||
import {
|
||||
decideSignerEnsure,
|
||||
isSignerCapable,
|
||||
@@ -3633,6 +3634,29 @@ class Application {
|
||||
return this._ui.updateActiveProfileUI(...args);
|
||||
}
|
||||
|
||||
// The active user's most-used hashtags (frequency-ranked from their own past
|
||||
// uploads) for the upload modal's one-tap suggestion chips (TODO #45).
|
||||
getUserHashtagSuggestions({ limit = 12 } = {}) {
|
||||
const pubkey = this.normalizeHexPubkey(this.pubkey);
|
||||
if (
|
||||
!pubkey ||
|
||||
typeof this.nostrService?.getActiveVideosByAuthors !== "function"
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
let videos = [];
|
||||
try {
|
||||
videos = this.nostrService.getActiveVideosByAuthors([pubkey]) || [];
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[Application] Failed to read user videos for hashtag suggestions:",
|
||||
error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
return rankHashtagsByFrequency(videos, { limit });
|
||||
}
|
||||
|
||||
async hydrateNwcSettingsForPubkey(pubkey) {
|
||||
return this.nwcSettingsService.hydrateNwcSettingsForPubkey(pubkey);
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ export class UploadModal {
|
||||
container,
|
||||
onRequestStorageSettings,
|
||||
onRequestUnlock,
|
||||
getHashtagSuggestions,
|
||||
} = {}) {
|
||||
this.authService = authService || null;
|
||||
this.r2Service = r2Service || null;
|
||||
@@ -75,6 +76,8 @@ export class UploadModal {
|
||||
typeof showSuccess === "function" ? showSuccess : () => {};
|
||||
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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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;
|
||||
@@ -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" }]), []);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user