mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-05 20:32:27 +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
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user