-
-
-
-
-
-
-
-
-
-
-
-
-
+
diff --git a/js/ui/components/EditModal.js b/js/ui/components/EditModal.js
index 6c2523ee..51fdd842 100644
--- a/js/ui/components/EditModal.js
+++ b/js/ui/components/EditModal.js
@@ -101,6 +101,11 @@ export class EditModal {
this.nip71SectionKey = "edit";
this.originalNip71Metadata = null;
this.originalNip71MetadataJson = null;
+ // Full stored NIP-71 metadata for the active video. The form now edits only
+ // hashtags (the rest of the NIP-71 section was removed for a cleaner UI), so
+ // legacy extras (imeta, text tracks, participants, …) must be merged back in
+ // on save rather than replaced by the reduced form's empty output.
+ this.storedNip71Metadata = null;
}
addEventListener(type, listener, options) {
@@ -385,6 +390,7 @@ export class EditModal {
this.nip71FormManager.resetSection(this.nip71SectionKey);
this.originalNip71Metadata = null;
this.originalNip71MetadataJson = null;
+ this.storedNip71Metadata = null;
this.setSubmitState({ pending: false });
return;
}
@@ -430,6 +436,7 @@ export class EditModal {
this.nip71FormManager.resetSection(this.nip71SectionKey);
this.originalNip71Metadata = null;
this.originalNip71MetadataJson = null;
+ this.storedNip71Metadata = null;
this.activeVideo = null;
this.setSubmitState({ pending: false });
}
@@ -511,7 +518,19 @@ export class EditModal {
const initialNip71 = this.nip71FormManager.collectSection(this.nip71SectionKey);
this.originalNip71Metadata = this.cloneNip71Metadata(initialNip71);
+ // Apply the SAME normalization submit applies to its collected copy, so the
+ // nip71Edited JSON comparison is between identically-shaped objects. Without
+ // this, submit's publishedAt string→timestamp / delete-empty step made every
+ // save look "edited" even when nothing changed.
+ this.normalizeCollectedNip71(this.originalNip71Metadata);
this.originalNip71MetadataJson = JSON.stringify(this.originalNip71Metadata);
+ // Keep the video's FULL stored NIP-71 metadata (pre-form): the reduced form
+ // only round-trips hashtags, and submit merges them into this so legacy
+ // extras survive a save.
+ this.storedNip71Metadata =
+ video.nip71 && typeof video.nip71 === "object"
+ ? this.cloneNip71Metadata(video.nip71)
+ : null;
this.activeVideo = editContext;
if (this.root) {
@@ -888,6 +907,28 @@ export class EditModal {
}
}
+ // Normalize a collectSection() result in place: convert the datetime-local
+ // publishedAt string back to unix seconds and drop it when empty/invalid.
+ // Applied to BOTH the open-time original and the submit-time copy so the
+ // nip71Edited comparison sees identically-shaped objects.
+ normalizeCollectedNip71(metadata) {
+ if (!metadata || typeof metadata.publishedAt !== "string") {
+ return metadata;
+ }
+ const dateStr = metadata.publishedAt;
+ if (dateStr) {
+ const dt = new Date(dateStr);
+ if (!isNaN(dt.getTime())) {
+ metadata.publishedAt = Math.floor(dt.getTime() / 1000);
+ } else {
+ delete metadata.publishedAt;
+ }
+ } else {
+ delete metadata.publishedAt;
+ }
+ return metadata;
+ }
+
async submit() {
if (this.pendingSubmit) {
return;
@@ -996,21 +1037,7 @@ export class EditModal {
const rawNip71 = this.nip71FormManager.collectSection(this.nip71SectionKey);
const currentNip71 = this.cloneNip71Metadata(rawNip71);
-
- // Convert publishedAt back to Unix timestamp (seconds)
- if (currentNip71 && typeof currentNip71.publishedAt === "string") {
- const dateStr = currentNip71.publishedAt;
- if (dateStr) {
- const dt = new Date(dateStr);
- if (!isNaN(dt.getTime())) {
- currentNip71.publishedAt = Math.floor(dt.getTime() / 1000);
- } else {
- delete currentNip71.publishedAt;
- }
- } else {
- delete currentNip71.publishedAt;
- }
- }
+ this.normalizeCollectedNip71(currentNip71);
const hasImetaSource = Array.isArray(currentNip71?.imeta)
? currentNip71.imeta.some((variant) => {
if (!variant || typeof variant !== "object") {
@@ -1170,7 +1197,25 @@ export class EditModal {
const currentNip71Json = JSON.stringify(currentNip71);
const nip71Edited = currentNip71Json !== this.originalNip71MetadataJson;
- updatedData.nip71 = currentNip71;
+ // The form now edits ONLY hashtags. Merge them into the video's full stored
+ // NIP-71 metadata so legacy extras (imeta, text tracks, participants, …) are
+ // preserved on save instead of being replaced by the reduced form's empty
+ // fields. (Both sides of the nip71Edited comparison round-trip through the
+ // same reduced form, so it still fires exactly on hashtag changes.)
+ let finalNip71 = currentNip71;
+ if (this.storedNip71Metadata && typeof this.storedNip71Metadata === "object") {
+ const preserved = this.cloneNip71Metadata(this.storedNip71Metadata) || {};
+ const editedHashtags = Array.isArray(currentNip71?.hashtags)
+ ? currentNip71.hashtags
+ : [];
+ preserved.hashtags = editedHashtags;
+ if (Array.isArray(preserved.t)) {
+ // Some stored payloads mirror hashtags under `t`; keep them in sync.
+ preserved.t = editedHashtags;
+ }
+ finalNip71 = preserved;
+ }
+ updatedData.nip71 = finalNip71;
updatedData.nip71Edited = nip71Edited;
const originalEvent = {
diff --git a/tests/edit-modal-submit-state.test.mjs b/tests/edit-modal-submit-state.test.mjs
index 56d4ecfc..e2bfb2ee 100644
--- a/tests/edit-modal-submit-state.test.mjs
+++ b/tests/edit-modal-submit-state.test.mjs
@@ -310,3 +310,120 @@ test('does not show missing video error after modal closes', async () => {
'should not surface missing video error when closed',
);
});
+
+// NIP-71 extras must survive a save now that the edit form only exposes hashtags
+// (the rest of the NIP-71 section was removed for a cleaner UI). A hashtag edit
+// must merge into the video's stored metadata — never replace it with the reduced
+// form's empty fields.
+//
+// test_integrity_note:
+// change_type: ["new_tests"]
+// scenarios:
+// - id: SCN-edit-nip71-preserved
+// given: "a video whose stored nip71 carries imeta/textTracks/participants"
+// when: "the user edits hashtags in the reduced form and submits"
+// then: "updatedData.nip71 keeps the extras AND the new hashtags; nip71Edited reflects the hashtag change"
+// observable_outcomes:
+// - "imeta/textTracks/participants/publishedAt survive the save"
+// - "hashtags (and a mirrored t key) update to the edited set"
+// - "no hashtag change -> nip71Edited stays false"
+// determinism_controls:
+// - "JSDOM modal against the real component HTML; no network"
+// anti_cheat_rationale:
+// prevents: ["asserting the wiping (broken) behavior", "over-mocking internal logic"]
+// relaxation:
+// did_relax_any_assertion: false
+
+test('editing hashtags preserves stored NIP-71 extras instead of wiping them', async () => {
+ const modal = createModal({ eventTarget: new EventTarget() });
+ await modal.load({ container });
+
+ const storedNip71 = {
+ publishedAt: 1700000000,
+ imeta: [{ url: 'https://cdn.example.com/v.mp4', m: 'video/mp4' }],
+ textTracks: [{ url: 'https://cdn.example.com/v.vtt', type: 'captions' }],
+ participants: [{ pubkey: 'a'.repeat(64) }],
+ hashtags: ['old'],
+ t: ['old'],
+ };
+
+ const video = {
+ id: 'video-nip71',
+ pubkey: 'pubkey1',
+ title: 'Sample video',
+ url: 'https://example.com/video.mp4',
+ thumbnail: '',
+ description: '',
+ enableComments: true,
+ isPrivate: false,
+ isNsfw: false,
+ isForKids: false,
+ nip71: storedNip71,
+ };
+
+ await modal.open(video);
+
+ // Edit hashtags through the reduced form (same path as the UI's Add hashtag).
+ const entry = modal.nip71FormManager.addRepeaterEntry('edit', 't');
+ modal.nip71FormManager.setFieldValue(entry, 'value', 'newtag');
+
+ const submissions = [];
+ modal.addEventListener('video:edit-submit', (event) => {
+ submissions.push(event.detail);
+ });
+
+ await modal.submit();
+
+ assert.equal(submissions.length, 1, 'submit emitted');
+ const savedNip71 = submissions[0].updatedData.nip71;
+ assert.equal(submissions[0].updatedData.nip71Edited, true, 'hashtag change detected');
+
+ assert.deepEqual(savedNip71.imeta, storedNip71.imeta, 'imeta preserved');
+ assert.deepEqual(savedNip71.textTracks, storedNip71.textTracks, 'text tracks preserved');
+ assert.deepEqual(savedNip71.participants, storedNip71.participants, 'participants preserved');
+ assert.equal(savedNip71.publishedAt, storedNip71.publishedAt, 'publishedAt preserved');
+ assert.deepEqual(savedNip71.hashtags, ['old', 'newtag'], 'edited hashtags applied');
+ assert.deepEqual(savedNip71.t, ['old', 'newtag'], 'mirrored t key kept in sync');
+});
+
+test('submitting without touching hashtags leaves nip71Edited false and extras intact', async () => {
+ const modal = createModal({ eventTarget: new EventTarget() });
+ await modal.load({ container });
+
+ const storedNip71 = {
+ imeta: [{ url: 'https://cdn.example.com/v.mp4', m: 'video/mp4' }],
+ hashtags: ['keepme'],
+ };
+
+ const video = {
+ id: 'video-nip71-b',
+ pubkey: 'pubkey1',
+ title: 'Sample video',
+ url: 'https://example.com/video.mp4',
+ thumbnail: '',
+ description: '',
+ enableComments: true,
+ isPrivate: false,
+ isNsfw: false,
+ isForKids: false,
+ nip71: storedNip71,
+ };
+
+ await modal.open(video);
+
+ const submissions = [];
+ modal.addEventListener('video:edit-submit', (event) => {
+ submissions.push(event.detail);
+ });
+
+ await modal.submit();
+
+ assert.equal(submissions.length, 1);
+ assert.equal(submissions[0].updatedData.nip71Edited, false, 'no hashtag change');
+ assert.deepEqual(
+ submissions[0].updatedData.nip71.imeta,
+ storedNip71.imeta,
+ 'extras intact on an untouched save',
+ );
+ assert.deepEqual(submissions[0].updatedData.nip71.hashtags, ['keepme']);
+});
diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md
index 05400b22..c3687a2b 100644
--- a/todo/TODO_2026-06-20_pre-launch.md
+++ b/todo/TODO_2026-06-20_pre-launch.md
@@ -1759,3 +1759,32 @@ passphrase-encrypted). Items 51, 56, 57 are all facets of that.
(b) like/dislike a video, (c) block/unblock a creator, (d) subscribe to a channel,
(e) send a DM — each should show ONE passphrase prompt (not a blanket error), then
work; the single unlock should cover the rest until the next reload.
+
+### 58. Remove NIP-71 authoring UI from upload/edit modals (clean UI)
+- [x] **DONE 2026-07-02.** Decision (maintainer): bitvid authors kind-30078 and
+ MIRRORS to NIP-71 — it does not author NIP-71 directly — so the NIP-71 form
+ sections were confusing dead weight. Verified before removing: the My Videos
+ mirror (`buildNip71MirrorEvent`) derives EVERYTHING from the bitvid note
+ (title/url/magnet/thumbnail/description, duration+dims auto-captured at upload,
+ contentWarning from the NSFW toggle, hashtags), and the only consumer of the
+ form's NIP-71 metadata (`publishNip71Video`) is gated behind
+ `FEATURE_PUBLISH_NIP71: false`.
+ Removed: upload modal's "NIP-71 Technical" (content warning / duration /
+ summary+Customize) + IMETA variant editor; edit modal's kind selector,
+ published_at, alt, duration, content warning, summary, IMETA, text tracks,
+ segments, participants, references. KEPT: hashtags in both modals (they feed
+ hashtag feeds, the suggestion chips, and the mirror's `t` tags) and the web
+ seeds / torrent (xs) inputs (bitvid-note fields, not NIP-71).
+ Compatibility: existing videos' NIP-71 extras are PRESERVED on save — EditModal
+ keeps the full stored metadata (`storedNip71Metadata`) and merges the edited
+ hashtags into it instead of replacing it with the reduced form's empty output.
+ Also fixed a latent bug this exposed: `nip71Edited` compared a normalized
+ submit-side copy against an unnormalized original (publishedAt string vs
+ timestamp), so every save looked "edited"; both sides now share
+ `normalizeCollectedNip71`. Tests: +2 in `tests/edit-modal-submit-state.test.mjs`
+ (extras survive a hashtag edit; untouched save → nip71Edited false).
+- [ ] **Post-launch cleanup (Option B, optional):** delete the dormant
+ `FEATURE_PUBLISH_NIP71` auto-publish path, the unused `Nip71FormManager`
+ sections (imeta/text-track/segment/p/r + scalar inputs), and the nip71
+ plumbing through `videoNotePayload` that only the removed forms fed.
+ RevertModal keeps its READ-ONLY use of the manager (version-history display).