mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
[ui] Remove NIP-71 authoring sections from upload/edit modals (#58)
bitvid authors kind-30078 and MIRRORS to NIP-71; it never authors NIP-71 directly. Verified before removing: the My Videos mirror (buildNip71MirrorEvent) derives everything from the bitvid note itself (duration/dims are auto-captured at upload, contentWarning comes from the NSFW toggle, imeta is built from the video), and the only consumer of the form's NIP-71 metadata (publishNip71Video) is gated behind FEATURE_PUBLISH_NIP71: false — the fields fed a dead path and confused uploaders. Removed from the upload modal: "NIP-71 Technical" (content warning, duration, summary + Customize toggle) and the IMETA variant editor. Removed from the edit modal: kind (21/22) selector, published_at, alt, duration, content warning, summary, IMETA, text tracks, segments, participants, references. KEPT in both: hashtags (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). All the JS plumbing is missing-DOM tolerant, so no wiring changes were needed to collect. Compatibility: existing videos' NIP-71 extras survive a save. EditModal keeps the video's full stored metadata (storedNip71Metadata) and merges the edited hashtags into it on submit instead of replacing it with the reduced form's empty output (t mirror key kept in sync). Also fixes a latent pre-existing bug the reduction exposed: nip71Edited compared the submit-side copy AFTER publishedAt normalization (string→timestamp, delete-empty) against an UN-normalized original, so any video with publishedAt made every save look "edited". Both sides now share normalizeCollectedNip71. Tests: +2 in edit-modal-submit-state (stored extras survive a hashtag edit; untouched save keeps nip71Edited false). Modal/payload/mirror suites green (39 + 7). Lint + build green. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
84cec15483
commit
5466d97544
5 files changed
+219
-625
No files matched your search
@@ -57,492 +57,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NOTE: Keep this NIP-71 markup in sync with the upload and revert modals. -->
|
||||
<div
|
||||
class="space-y-4 rounded-lg border border-border/70 bg-overlay-black/30 p-4"
|
||||
id="editNip71EventDetails"
|
||||
data-nip71-section="event-details"
|
||||
>
|
||||
<fieldset
|
||||
id="editNip71KindFieldset"
|
||||
class="space-y-2"
|
||||
data-nip71-fieldset="kind"
|
||||
>
|
||||
<legend class="text-sm font-semibold text-text">
|
||||
NIP-71 event type
|
||||
</legend>
|
||||
<p class="text-xs text-muted">
|
||||
Choose the video format. <span class="font-medium text-muted-strong">Kind 21</span> is a full video, while
|
||||
<span class="font-medium text-muted-strong">Kind 22</span> is a short.
|
||||
</p>
|
||||
<div
|
||||
class="form-control"
|
||||
role="radiogroup"
|
||||
aria-labelledby="editNip71KindFieldset"
|
||||
>
|
||||
<div class="menu sm:flex-row sm:flex-wrap sm:py-0" role="presentation">
|
||||
<label class="menu__item gap-3 px-0 py-0" for="editNip71KindLong">
|
||||
<input
|
||||
type="radio"
|
||||
name="editNip71Kind"
|
||||
id="editNip71KindLong"
|
||||
value="21"
|
||||
checked
|
||||
data-nip71-input="kind"
|
||||
class="h-4 w-4 border-border bg-panel text-info focus:ring-info"
|
||||
/>
|
||||
<span class="text-sm text-text">Normal video (kind 21)</span>
|
||||
</label>
|
||||
<label class="menu__item gap-3 px-0 py-0" for="editNip71KindShort">
|
||||
<input
|
||||
type="radio"
|
||||
name="editNip71Kind"
|
||||
id="editNip71KindShort"
|
||||
value="22"
|
||||
data-nip71-input="kind"
|
||||
class="h-4 w-4 border-border bg-panel text-info focus:ring-info"
|
||||
/>
|
||||
<span class="text-sm text-text">Short video (kind 22)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2" data-nip71-grid="metadata">
|
||||
<div class="space-y-2">
|
||||
<label for="editNip71PublishedAt" class="block text-sm font-medium text-text">
|
||||
Published at
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
id="editNip71PublishedAt"
|
||||
data-nip71-input="published_at"
|
||||
class="input"
|
||||
/>
|
||||
<p class="text-xs text-muted">
|
||||
Set the canonical publish time for cross-client ordering.
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label for="editNip71AltText" class="block text-sm font-medium text-text">
|
||||
Alt text
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="editNip71AltText"
|
||||
data-nip71-input="alt"
|
||||
placeholder="Describe the visuals for screen readers"
|
||||
class="input"
|
||||
/>
|
||||
<p class="text-xs text-muted">
|
||||
Plain-language fallback shown when the media cannot load.
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label for="editNip71Duration" class="block text-sm font-medium text-text">
|
||||
Duration (seconds)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="editNip71Duration"
|
||||
min="0"
|
||||
step="0.001"
|
||||
data-nip71-input="duration"
|
||||
class="input"
|
||||
/>
|
||||
<p class="text-xs text-muted">
|
||||
Optional legacy fallback in seconds (fractions allowed) for clients that expect a top-level duration.
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label for="editNip71ContentWarning" class="block text-sm font-medium text-text">
|
||||
Content warning
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="editNip71ContentWarning"
|
||||
data-nip71-input="content-warning"
|
||||
placeholder="e.g., Flashing lights"
|
||||
class="input"
|
||||
/>
|
||||
<p class="text-xs text-muted">
|
||||
Leave blank if the video is safe for all audiences.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label for="editNip71Summary" class="block text-sm font-medium text-text">
|
||||
Summary
|
||||
</label>
|
||||
<textarea
|
||||
id="editNip71Summary"
|
||||
rows="3"
|
||||
data-nip71-input="summary"
|
||||
class="textarea"
|
||||
placeholder="Short synopsis used as the Nostr content field"
|
||||
></textarea>
|
||||
<p class="text-xs text-muted">
|
||||
This becomes the event content. Keep it concise for Nostr readers.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset
|
||||
id="editNip71TagsFieldset"
|
||||
class="space-y-6 rounded-lg border border-border/70 bg-overlay-black/30 p-4"
|
||||
data-nip71-section="tags"
|
||||
>
|
||||
<legend class="relative text-sm font-semibold text-text">
|
||||
<span class="inline-block -ml-2 rounded px-2 bg-overlay-black/30">NIP-71 tags</span>
|
||||
</legend>
|
||||
<p class="text-xs text-muted">
|
||||
Use the controls below to describe media variants, captions, chapters, participants, and references.
|
||||
</p>
|
||||
|
||||
<section class="space-y-4" aria-labelledby="editNip71ImetaLabel" data-nip71-repeater="imeta">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 id="editNip71ImetaLabel" class="text-sm font-medium text-text">Image metadata (imeta)</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-ghost focus-ring text-xs font-semibold"
|
||||
data-nip71-add="imeta"
|
||||
aria-describedby="editNip71ImetaHelp"
|
||||
>
|
||||
Add variant
|
||||
</button>
|
||||
</div>
|
||||
<p id="editNip71ImetaHelp" class="text-xs text-muted">
|
||||
Provide dimensions, MIME type, and resource links for each variant. Nested repeaters let you attach multiple
|
||||
<code>image</code>, <code>fallback</code>, or <code>service</code> values.
|
||||
</p>
|
||||
<div id="editNip71ImetaList" class="space-y-4" data-nip71-list="imeta"></div>
|
||||
<template id="editNip71ImetaTemplate" data-nip71-template="imeta">
|
||||
<article class="card space-y-3 bg-surface/60 p-4" data-nip71-entry="imeta">
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-medium text-muted-strong">
|
||||
MIME type (<code>m</code>)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
data-nip71-field="m"
|
||||
placeholder="video/mp4"
|
||||
class="input"
|
||||
/>
|
||||
<p class="text-3xs text-muted">RFC 2046 media type for this encoding.</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-medium text-muted-strong">
|
||||
Pixel dimensions (<code>dim</code>)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
data-nip71-field="dim"
|
||||
placeholder="1920x1080"
|
||||
class="input"
|
||||
/>
|
||||
<p class="text-3xs text-muted">Formatted as widthxheight.</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-medium text-muted-strong">
|
||||
Streaming URL (<code>url</code>)
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
data-nip71-field="url"
|
||||
placeholder="https://cdn.example.com/variant.m3u8"
|
||||
class="input"
|
||||
/>
|
||||
<p class="text-3xs text-muted">HTTPS variant URL shared across clients.</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-medium text-muted-strong">
|
||||
Magnet (<code>x</code>)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
data-nip71-field="x"
|
||||
placeholder="magnet:?xt=urn:btih:..."
|
||||
class="input"
|
||||
/>
|
||||
<p class="text-3xs text-muted">Optional magnet pointer if the variant is seeded via P2P.</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-medium text-muted-strong">
|
||||
Duration seconds (<code>duration</code>)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
data-nip71-field="duration"
|
||||
min="0"
|
||||
step="0.001"
|
||||
placeholder="29.97"
|
||||
class="input"
|
||||
/>
|
||||
<p class="text-3xs text-muted">Fractional seconds supported; clients use this for precise timelines.</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-medium text-muted-strong">
|
||||
Bitrate bps (<code>bitrate</code>)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
data-nip71-field="bitrate"
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="3500000"
|
||||
class="input"
|
||||
/>
|
||||
<p class="text-3xs text-muted">Average bits per second for this variant.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3" data-nip71-nested="image">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted">Image values</h4>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-ghost focus-ring text-3xs font-medium"
|
||||
data-nip71-nested-add="image"
|
||||
>
|
||||
Add image URL
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-2" data-nip71-nested-list="image"></div>
|
||||
<template data-nip71-nested-template="image">
|
||||
<div class="flex items-center gap-2" data-nip71-nested-entry="image">
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://cdn.example.com/poster.jpg"
|
||||
data-nip71-nested-field="image"
|
||||
class="flex-1 input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-ghost focus-ring text-3xs font-medium"
|
||||
data-nip71-remove="nested"
|
||||
aria-label="Remove image URL"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3" data-nip71-nested="fallback">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted">Fallback URLs</h4>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-ghost focus-ring text-3xs font-medium"
|
||||
data-nip71-nested-add="fallback"
|
||||
>
|
||||
Add fallback
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-2" data-nip71-nested-list="fallback"></div>
|
||||
<template data-nip71-nested-template="fallback">
|
||||
<div class="flex items-center gap-2" data-nip71-nested-entry="fallback">
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://cdn.backup.net/video.mp4"
|
||||
data-nip71-nested-field="fallback"
|
||||
class="flex-1 input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-ghost focus-ring text-3xs font-medium"
|
||||
data-nip71-remove="nested"
|
||||
aria-label="Remove fallback URL"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3" data-nip71-nested="service">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted">Services</h4>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-ghost focus-ring text-3xs font-medium"
|
||||
data-nip71-nested-add="service"
|
||||
>
|
||||
Add service
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-2" data-nip71-nested-list="service"></div>
|
||||
<template data-nip71-nested-template="service">
|
||||
<div class="flex items-center gap-2" data-nip71-nested-entry="service">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://api.example.com"
|
||||
data-nip71-nested-field="service"
|
||||
class="flex-1 input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-ghost focus-ring text-3xs font-medium"
|
||||
data-nip71-remove="nested"
|
||||
aria-label="Remove service entry"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-ghost focus-ring text-3xs font-medium"
|
||||
data-nip71-remove="imeta"
|
||||
>
|
||||
Remove variant
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4" aria-labelledby="editNip71TextTracksLabel" data-nip71-repeater="text-track">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 id="editNip71TextTracksLabel" class="text-sm font-medium text-text">
|
||||
Caption & subtitle tracks (text-track)
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-ghost focus-ring text-xs font-semibold"
|
||||
data-nip71-add="text-track"
|
||||
aria-describedby="editNip71TextTracksHelp"
|
||||
>
|
||||
Add track
|
||||
</button>
|
||||
</div>
|
||||
<p id="editNip71TextTracksHelp" class="text-xs text-muted">
|
||||
Supply caption or subtitle URLs along with file type and language so players can surface accessibility features.
|
||||
</p>
|
||||
<div id="editNip71TextTrackList" class="space-y-4" data-nip71-list="text-track"></div>
|
||||
<template id="editNip71TextTrackTemplate" data-nip71-template="text-track">
|
||||
<article class="card space-y-3 bg-surface/60 p-4" data-nip71-entry="text-track">
|
||||
<div class="grid gap-3 md:grid-cols-3">
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-medium text-muted-strong">Track URL</label>
|
||||
<input
|
||||
type="url"
|
||||
data-nip71-field="url"
|
||||
placeholder="https://cdn.example.com/subs.vtt"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-medium text-muted-strong">Type</label>
|
||||
<input
|
||||
type="text"
|
||||
data-nip71-field="type"
|
||||
placeholder="text/vtt"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-medium text-muted-strong">Language</label>
|
||||
<input
|
||||
type="text"
|
||||
data-nip71-field="language"
|
||||
placeholder="en"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-ghost focus-ring text-3xs font-medium"
|
||||
data-nip71-remove="text-track"
|
||||
>
|
||||
Remove track
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4" aria-labelledby="editNip71SegmentsLabel" data-nip71-repeater="segment">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 id="editNip71SegmentsLabel" class="text-sm font-medium text-text">Chapters & segments (segment)</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-ghost focus-ring text-xs font-semibold"
|
||||
data-nip71-add="segment"
|
||||
aria-describedby="editNip71SegmentsHelp"
|
||||
>
|
||||
Add segment
|
||||
</button>
|
||||
</div>
|
||||
<p id="editNip71SegmentsHelp" class="text-xs text-muted">
|
||||
Outline chapters with start and end times, titles, and optional thumbnails.
|
||||
</p>
|
||||
<div id="editNip71SegmentList" class="space-y-4" data-nip71-list="segment"></div>
|
||||
<template id="editNip71SegmentTemplate" data-nip71-template="segment">
|
||||
<article class="card space-y-3 bg-surface/60 p-4" data-nip71-entry="segment">
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-medium text-muted-strong">Start (seconds)</label>
|
||||
<input
|
||||
type="number"
|
||||
data-nip71-field="start"
|
||||
min="0"
|
||||
step="1"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-medium text-muted-strong">End (seconds)</label>
|
||||
<input
|
||||
type="number"
|
||||
data-nip71-field="end"
|
||||
min="0"
|
||||
step="1"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-medium text-muted-strong">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
data-nip71-field="title"
|
||||
placeholder="Chapter title"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-medium text-muted-strong">Thumbnail URL</label>
|
||||
<input
|
||||
type="url"
|
||||
data-nip71-field="thumbnail"
|
||||
placeholder="https://cdn.example.com/frame.jpg"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-ghost focus-ring text-3xs font-medium"
|
||||
data-nip71-remove="segment"
|
||||
>
|
||||
Remove segment
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- Hashtags (kept from the old NIP-71 section: they feed bitvid's hashtag
|
||||
feeds and the NIP-71 mirror; everything else in that section fed the
|
||||
disabled FEATURE_PUBLISH_NIP71 path and was removed for a cleaner UI.
|
||||
Existing videos' extra NIP-71 metadata is preserved on save. -->
|
||||
<div class="space-y-4 rounded-lg border border-border/70 bg-overlay-black/30 p-4">
|
||||
<section class="space-y-4" aria-labelledby="editNip71HashtagsLabel" data-nip71-repeater="t">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 id="editNip71HashtagsLabel" class="text-sm font-medium text-text">Hashtags (<code>t</code>)</h3>
|
||||
<h3 id="editNip71HashtagsLabel" class="text-sm font-medium text-text">Hashtags</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-ghost focus-ring text-xs font-semibold"
|
||||
@@ -552,7 +74,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-muted">
|
||||
Enter keywords; leading <code>#</code> characters are stripped automatically when saving.
|
||||
Tag your video so it shows up in hashtag feeds. Leading <code>#</code> characters are stripped automatically.
|
||||
</p>
|
||||
<div id="editNip71HashtagList" class="space-y-2" data-nip71-list="t"></div>
|
||||
<template id="editNip71HashtagTemplate" data-nip71-template="t">
|
||||
@@ -573,85 +95,7 @@
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4" aria-labelledby="editNip71ParticipantsLabel" data-nip71-repeater="p">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 id="editNip71ParticipantsLabel" class="text-sm font-medium text-text">Participants (<code>p</code>)</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-ghost focus-ring text-xs font-semibold"
|
||||
data-nip71-add="p"
|
||||
>
|
||||
Add participant
|
||||
</button>
|
||||
</div>
|
||||
<div id="editNip71ParticipantList" class="space-y-3" data-nip71-list="p"></div>
|
||||
<template id="editNip71ParticipantTemplate" data-nip71-template="p">
|
||||
<article class="card space-y-3 bg-surface/60 p-4" data-nip71-entry="p">
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-medium text-muted-strong">Participant pubkey</label>
|
||||
<input
|
||||
type="text"
|
||||
data-nip71-field="pubkey"
|
||||
placeholder="npub1... or hex"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-medium text-muted-strong">Relay hint</label>
|
||||
<input
|
||||
type="text"
|
||||
data-nip71-field="relay"
|
||||
placeholder="wss://relay.example.com"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-ghost focus-ring text-3xs font-medium"
|
||||
data-nip71-remove="p"
|
||||
>
|
||||
Remove participant
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4" aria-labelledby="editNip71ReferencesLabel" data-nip71-repeater="r">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 id="editNip71ReferencesLabel" class="text-sm font-medium text-text">Reference links (<code>r</code>)</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-ghost focus-ring text-xs font-semibold"
|
||||
data-nip71-add="r"
|
||||
>
|
||||
Add reference
|
||||
</button>
|
||||
</div>
|
||||
<div id="editNip71ReferenceList" class="space-y-2" data-nip71-list="r"></div>
|
||||
<template id="editNip71ReferenceTemplate" data-nip71-template="r">
|
||||
<div class="flex items-center gap-2" data-nip71-entry="r">
|
||||
<input
|
||||
type="url"
|
||||
data-nip71-field="url"
|
||||
placeholder="https://example.com/context"
|
||||
class="flex-1 input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-ghost focus-ring text-3xs font-medium"
|
||||
data-nip71-remove="r"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</fieldset>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label for="editVideoUrl" class="block text-sm font-medium text-text">
|
||||
|
||||
@@ -330,52 +330,11 @@
|
||||
|
||||
<div id="section-advanced" class="hidden mt-4 space-y-6 pl-2 border-l-2 border-border/30">
|
||||
|
||||
<!-- NIP-71 Technical -->
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-medium text-muted-strong">Content Warning</label>
|
||||
<input id="input-content-warning" type="text" data-nip71-input="content-warning" class="input text-sm" placeholder="e.g. Nudity" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-medium text-muted-strong">Duration (sec)</label>
|
||||
<input id="input-duration" type="number" step="1" data-nip71-input="duration" class="input text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="block text-xs font-medium text-muted-strong">Summary (NIP-71 Content)</label>
|
||||
<label class="flex items-center gap-2 text-xs text-muted cursor-pointer">
|
||||
<input type="checkbox" id="check-summary-unlock" class="checkbox h-3 w-3" />
|
||||
Customize
|
||||
</label>
|
||||
</div>
|
||||
<textarea id="input-summary" rows="2" data-nip71-input="summary" class="textarea text-sm text-muted" readonly></textarea>
|
||||
</div>
|
||||
|
||||
<!-- IMETA Editor -->
|
||||
<div class="space-y-3" data-nip71-repeater="imeta">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-sm font-semibold text-text">Video Variants (IMETA)</h4>
|
||||
<button type="button" class="btn-ghost px-2 py-1 text-xs" data-nip71-add="imeta">Add Variant</button>
|
||||
</div>
|
||||
<div id="imeta-list" class="space-y-4" data-nip71-list="imeta"></div>
|
||||
|
||||
<!-- IMETA Template -->
|
||||
<template data-nip71-template="imeta">
|
||||
<article class="rounded-md border border-border/70 bg-surface/50 p-3 space-y-3" data-nip71-entry="imeta">
|
||||
<div class="grid gap-2 md:grid-cols-2 text-xs">
|
||||
<input type="text" data-nip71-field="m" placeholder="MIME (video/mp4)" class="input" />
|
||||
<input type="text" data-nip71-field="dim" placeholder="Dimensions (1920x1080)" class="input" />
|
||||
<input type="url" data-nip71-field="url" placeholder="URL" class="input md:col-span-2" />
|
||||
<input type="text" data-nip71-field="x" placeholder="Magnet" class="input md:col-span-2" />
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button type="button" class="text-xs text-critical hover:underline" data-nip71-remove="imeta">Remove Variant</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</div>
|
||||
<!-- The old "NIP-71 Technical" inputs (content warning, duration,
|
||||
summary, IMETA variants) were removed for a cleaner UI: they fed
|
||||
the disabled FEATURE_PUBLISH_NIP71 path, and everything the NIP-71
|
||||
mirror needs (duration/dimensions, content warning from the NSFW
|
||||
toggle, the primary imeta) is derived automatically. -->
|
||||
|
||||
<!-- Additional Technical Sections (Web Seeds, etc) -->
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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).
|
||||
Reference in new issue
Block a user