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 @@
+
+
Reuse a tag from your videos:
+
+
{};
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