diff --git a/components/revert-video-modal.html b/components/revert-video-modal.html index 87f2e6d7..cedd0daa 100644 --- a/components/revert-video-modal.html +++ b/components/revert-video-modal.html @@ -87,8 +87,64 @@ class="rounded-lg border border-gray-800 bg-gray-900/70 p-4 text-sm text-gray-200" aria-live="polite" > -
- Select an earlier revision to inspect its metadata. + +
+
+

+ Revision metadata +

+

+ Select an earlier revision to inspect its metadata. +

+
+ +
+

+ Playback sources +

+
+
+
Hosted URL
+
Awaiting selection
+
+
+
Magnet
+
Awaiting selection
+
+
+
+ +
+

+ NIP-71 event metadata +

+
+
+
Kind
+
Awaiting selection
+
+
+
Duration
+
Awaiting selection
+
+
+
Summary
+
Awaiting selection
+
+
+
+ +
+

+ NIP-71 media metadata +

+

+ Media variants, text tracks, chapters, hashtags, participants, and references will appear once a revision is selected. +

+
diff --git a/js/ui/components/RevertModal.js b/js/ui/components/RevertModal.js index c434e1e1..5e287b69 100644 --- a/js/ui/components/RevertModal.js +++ b/js/ui/components/RevertModal.js @@ -1,3 +1,7 @@ +import { Nip71FormManager } from "./nip71FormManager.js"; + +// NOTE: Any metadata field added to the Upload or Edit modals must also be +// rendered inside the Revert modal to keep the experiences aligned. export class RevertModal { constructor({ removeTrackingScripts, @@ -18,6 +22,8 @@ export class RevertModal { this.fallbackThumbnailSrc = fallbackThumbnailSrc || ""; this.container = container || document.getElementById("modalContainer"); + this.nip71Formatter = new Nip71FormManager(); + this.eventTarget = new EventTarget(); this.modal = null; @@ -540,27 +546,24 @@ export class RevertModal { const isPrivate = version.isPrivate === true; const dTagValue = this.extractDTagValue(version.tags); - const escape = (value) => - this.escapeHTML ? this.escapeHTML(String(value ?? "")) : String(value ?? ""); + const escape = this.getEscapeFn(); + const nip71Metadata = this.buildNip71DisplayMetadata(version); const fallbackThumbnail = escape(this.fallbackThumbnailSrc); const thumbnailSrc = thumbnail ? escape(thumbnail) : fallbackThumbnail; const thumbnailAlt = thumbnail ? "Revision thumbnail" : "Fallback thumbnail"; - let urlHtml = 'None'; + let urlHtml = this.renderPlaceholder("None"); if (url) { - const safeUrl = escape(url); - const displayUrl = this.truncateMiddle - ? escape(this.truncateMiddle(url, 72)) - : safeUrl; - urlHtml = `${displayUrl}`; + urlHtml = this.buildLinkMarkup(url); } - let magnetHtml = 'None'; + let magnetHtml = this.renderPlaceholder("None"); if (displayMagnet) { - const label = this.truncateMiddle - ? escape(this.truncateMiddle(displayMagnet, 72)) - : escape(displayMagnet); + const labelRaw = this.truncateMiddle + ? this.truncateMiddle(displayMagnet, 72) + : displayMagnet; + const label = escape(labelRaw); const caption = isPrivate ? 'Magnet stays visible only to you — private notes keep the raw string local.' : ""; @@ -587,9 +590,7 @@ export class RevertModal { } const descriptionHtml = description - ? `

${escape( - description - )}

` + ? `

${escape(description)}

` : '

No description provided.

'; const rootId = @@ -605,8 +606,120 @@ export class RevertModal { : escape(version.id) : ""; + const timestampParts = []; + if (absolute) { + timestampParts.push(escape(absolute)); + } + if (relative) { + timestampParts.push(`(${escape(relative)})`); + } + const timestampHtml = timestampParts.length + ? timestampParts.join(" ") + : escape(absolute || relative || ""); + + const formatTimestamp = (seconds, raw) => { + if (Number.isFinite(seconds)) { + const absoluteLabel = this.formatAbsoluteTimestamp + ? this.formatAbsoluteTimestamp(seconds) + : `${seconds}`; + const relativeLabel = this.formatTimeAgo + ? this.formatTimeAgo(seconds) + : ""; + const parts = []; + if (absoluteLabel) { + parts.push(`${escape(absoluteLabel)}`); + } + if (relativeLabel) { + parts.push(`(${escape(relativeLabel)})`); + } + if (parts.length) { + return parts.join(" "); + } + } + if (raw) { + return `${escape(raw)}`; + } + return this.renderPlaceholder(); + }; + + const formatDuration = (seconds, raw) => { + if (Number.isFinite(seconds)) { + const display = this.formatDurationSeconds(seconds); + const suffix = seconds === 1 ? "second" : "seconds"; + return `${escape(display)} (${escape( + `${seconds} ${suffix}` + )})`; + } + if (raw) { + return `${escape(raw)}`; + } + return this.renderPlaceholder(); + }; + + const kindValue = nip71Metadata.kind; + let kindHtml = this.renderPlaceholder(); + if (kindValue !== "" && kindValue !== null && kindValue !== undefined) { + const label = typeof kindValue === "number" ? kindValue : `${kindValue}`; + const numeric = Number(label); + let suffix = ""; + if (Number.isFinite(numeric)) { + if (numeric === 22) { + suffix = "short"; + } else if (numeric === 21) { + suffix = "video"; + } + } + const badge = `kind ${escape( + label + )}`; + kindHtml = suffix + ? `${badge} (${escape(suffix)})` + : badge; + } + + const summaryHtml = nip71Metadata.summary + ? `

${escape( + nip71Metadata.summary + )}

` + : this.renderPlaceholder("Not provided"); + const contentWarningHtml = nip71Metadata.contentWarning + ? `${escape( + nip71Metadata.contentWarning + )}` + : this.renderPlaceholder("Not provided"); + const publishedAtHtml = formatTimestamp( + nip71Metadata.publishedAtSeconds, + nip71Metadata.publishedAtRaw + ); + const durationHtml = formatDuration( + nip71Metadata.durationSeconds, + nip71Metadata.durationRaw + ); + const altHtml = nip71Metadata.alt + ? `

${escape(nip71Metadata.alt)}

` + : this.renderPlaceholder("Not provided"); + + const definition = (label, valueHtml, { span = 1 } = {}) => { + const colSpan = span > 1 ? "sm:col-span-2" : ""; + return ` +
+
${escape(label)}
+
${valueHtml}
+
+ `; + }; + + const eventMetadataRows = [ + definition("NIP-71 kind", kindHtml), + definition("Summary", summaryHtml, { span: 2 }), + definition("Content warning", contentWarningHtml, { span: 2 }), + definition("Published timestamp", publishedAtHtml), + definition("Duration", durationHtml), + definition("Alt text", altHtml, { span: 2 }), + ].join(""); + this.details.innerHTML = ` -
+
${escape( version.title || "Untitled" )} -

${escape(absolute)} (${escape( - relative - )})

+

${timestampHtml}

${ chips.length ? `
${chips.join("")}
` : "" } -
+
- Hosted URL: -
${urlHtml}
+
Hosted URL
+
${urlHtml}
- Magnet: -
${magnetHtml}
+
Magnet
+
${magnetHtml}
-
+
-
+

Description

${descriptionHtml} -
+ -
-
-
Mode
-
${escape(version.mode || "live")}
+
+

NIP-71 event metadata

+
+ ${eventMetadataRows} +
+
+ +
+

Note pointers

+
+
+
Mode
+
${escape(version.mode || "live")}
+
+
+
d tag
+
+ ${ + dTagValue + ? `${escape( + dTagValue + )}` + : 'Not provided' + } +
+
+
+
videoRootId
+
+ ${ + rootDisplay + ? `${rootDisplay}` + : 'Not provided' + } +
+
+
+
Event ID
+
+ ${ + eventDisplay + ? `${eventDisplay}` + : 'Unknown' + } +
+
+
+
+ +
+

NIP-71 media metadata

+
+
+
Media variants (imeta)
+ ${this.renderImetaVariants(nip71Metadata.imeta)} +
+
+
Caption tracks
+ ${this.renderTextTracks(nip71Metadata.textTracks)} +
+
+
Chapters
+ ${this.renderSegments(nip71Metadata.segments)} +
+
+
Hashtags
+ ${this.renderHashtags(nip71Metadata.hashtags)} +
+
+
Participants
+ ${this.renderParticipants(nip71Metadata.participants)} +
+
+
References
+ ${this.renderReferences(nip71Metadata.references)} +
-
-
d tag
-
- ${ - dTagValue - ? `${escape( - dTagValue - )}` - : 'Not provided' - } -
-
-
-
videoRootId
-
- ${ - rootDisplay - ? `${rootDisplay}` - : 'Not provided' - } -
-
-
-
Event ID
-
- ${ - eventDisplay - ? `${eventDisplay}` - : 'Unknown' - } -
-
-
+
`; } @@ -850,6 +1001,534 @@ export class RevertModal { this.close(); } + getEscapeFn() { + return (value) => + this.escapeHTML ? this.escapeHTML(String(value ?? "")) : String(value ?? ""); + } + + renderPlaceholder(text = "Not provided") { + const escape = this.getEscapeFn(); + return `${escape(text)}`; + } + + renderListEmpty(text) { + const escape = this.getEscapeFn(); + return `

${escape(text)}

`; + } + + buildLinkMarkup(value, { breakAll = true } = {}) { + if (value == null) { + return ""; + } + + const escape = this.getEscapeFn(); + const raw = typeof value === "string" ? value.trim() : String(value ?? "").trim(); + if (!raw) { + return ""; + } + + const displayRaw = this.truncateMiddle + ? this.truncateMiddle(raw, 96) + : raw; + const safeDisplay = escape(displayRaw); + const safeValue = escape(raw); + const breakClass = breakAll ? " break-all" : " break-words"; + + if (/^https?:/i.test(raw)) { + return `${safeDisplay}`; + } + + return `${safeDisplay}`; + } + + formatDurationSeconds(seconds) { + if (!Number.isFinite(seconds)) { + return ""; + } + + const total = Math.max(0, Math.floor(seconds)); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const secs = total % 60; + + if (hours > 0) { + return `${hours}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`; + } + + return `${minutes}:${String(secs).padStart(2, "0")}`; + } + + buildNip71DisplayMetadata(version) { + const raw = + version && typeof version === "object" && version.nip71 && typeof version.nip71 === "object" + ? version.nip71 + : {}; + + const { normalizeNumber, toInputValue } = this.nip71Formatter || {}; + + const toString = (value) => { + if (value == null) { + return ""; + } + if (typeof value === "string") { + return value.trim(); + } + if (typeof value === "number" || typeof value === "boolean") { + const converted = toInputValue ? toInputValue(value) : `${value}`; + return typeof converted === "string" ? converted.trim() : ""; + } + return ""; + }; + + const parseNumeric = (value) => { + if (value == null) { + return null; + } + if (typeof value === "string" && value.includes(":")) { + return null; + } + if (normalizeNumber) { + const parsed = normalizeNumber(value); + if (typeof parsed === "number" && Number.isFinite(parsed)) { + return parsed; + } + } + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + const numeric = Number(trimmed); + return Number.isFinite(numeric) ? numeric : null; + } + return null; + }; + + const parseTimestamp = (value) => { + const rawValue = toString(value); + if (!rawValue) { + return { raw: "", seconds: null }; + } + + if (/^\d+(?:\.\d+)?$/.test(rawValue)) { + const numeric = Number(rawValue); + if (Number.isFinite(numeric)) { + const normalized = numeric > 1e12 ? Math.floor(numeric / 1000) : Math.floor(numeric); + return { raw: rawValue, seconds: normalized }; + } + } + + const parsed = Date.parse(rawValue); + if (!Number.isNaN(parsed)) { + return { raw: rawValue, seconds: Math.floor(parsed / 1000) }; + } + + return { raw: rawValue, seconds: null }; + }; + + const collectStrings = (values) => { + if (!Array.isArray(values)) { + return []; + } + return values + .map((entry) => toString(entry)) + .map((entry) => entry.trim()) + .filter((entry) => Boolean(entry)); + }; + + const collectObjects = (values, mapFn) => { + if (!Array.isArray(values)) { + return []; + } + return values + .map((entry) => (entry && typeof entry === "object" ? mapFn(entry) : null)) + .filter((entry) => entry); + }; + + const kindValue = parseNumeric(raw.kind); + const publishedAt = parseTimestamp( + raw.publishedAt ?? raw.published_at ?? raw["published-at"] + ); + + const durationNumeric = parseNumeric(raw.duration); + const durationRaw = durationNumeric != null ? `${durationNumeric}` : toString(raw.duration); + + const imeta = collectObjects(raw.imeta, (variant) => { + const mapped = { + m: toString(variant.m), + dim: toString(variant.dim), + url: toString(variant.url), + x: toString(variant.x), + image: collectStrings(variant.image), + fallback: collectStrings(variant.fallback), + service: collectStrings(variant.service), + autoGenerated: variant.autoGenerated === true, + }; + + const hasContent = + mapped.m || + mapped.dim || + mapped.url || + mapped.x || + mapped.image.length || + mapped.fallback.length || + mapped.service.length; + + return hasContent ? mapped : null; + }); + + const textTracks = collectObjects(raw.textTracks, (track) => { + const mapped = { + url: toString(track.url), + type: toString(track.type), + language: toString(track.language), + }; + return mapped.url || mapped.type || mapped.language ? mapped : null; + }); + + const segments = collectObjects(raw.segments, (segment) => { + const startNumeric = parseNumeric(segment.start); + const endNumeric = parseNumeric(segment.end); + const mapped = { + startSeconds: startNumeric != null ? Math.max(0, startNumeric) : null, + endSeconds: endNumeric != null ? Math.max(0, endNumeric) : null, + startRaw: toString(segment.start), + endRaw: toString(segment.end), + title: toString(segment.title), + thumbnail: toString(segment.thumbnail), + }; + return mapped.startRaw || mapped.endRaw || mapped.title || mapped.thumbnail + ? mapped + : null; + }); + + const hashtags = collectStrings(raw.hashtags); + const participants = collectObjects(raw.participants, (participant) => { + const mapped = { + pubkey: toString(participant.pubkey), + relay: toString(participant.relay), + }; + return mapped.pubkey || mapped.relay ? mapped : null; + }); + const references = collectStrings(raw.references); + + return { + kind: kindValue != null ? kindValue : toString(raw.kind), + summary: toString(raw.summary), + contentWarning: + toString(raw.contentWarning ?? raw["content-warning"] ?? raw.content_warning), + publishedAtSeconds: publishedAt.seconds, + publishedAtRaw: publishedAt.raw, + durationSeconds: durationNumeric != null ? Math.max(0, durationNumeric) : null, + durationRaw, + alt: toString(raw.alt), + imeta, + textTracks, + segments, + hashtags, + participants, + references, + }; + } + + renderImetaVariants(variants) { + if (!Array.isArray(variants) || variants.length === 0) { + return this.renderListEmpty("No media variants provided."); + } + + const escape = this.getEscapeFn(); + + return `
    ${variants + .map((variant, index) => { + if (!variant) { + return ""; + } + + const badges = []; + if (variant.autoGenerated) { + badges.push( + 'Auto-generated' + ); + } + + const variantNumber = `Variant ${index + 1}`; + + const mimeHtml = variant.m + ? `${escape( + variant.m + )}` + : this.renderPlaceholder(); + const dimHtml = variant.dim + ? `${escape( + variant.dim + )}` + : this.renderPlaceholder(); + const urlHtml = variant.url + ? this.buildLinkMarkup(variant.url) + : this.renderPlaceholder("No URL"); + const hashHtml = variant.x + ? `${escape( + variant.x + )}` + : this.renderPlaceholder("Not provided"); + + const renderNestedList = (label, values, emptyLabel) => { + if (!Array.isArray(values) || values.length === 0) { + return `

    ${escape( + label + )}

    ${this.renderListEmpty(emptyLabel)}
    `; + } + + const items = values + .map((entry) => this.buildLinkMarkup(entry)) + .filter(Boolean) + .map((markup) => `
  1. ${markup}
  2. `); + + if (!items.length) { + return `

    ${escape( + label + )}

    ${this.renderListEmpty(emptyLabel)}
    `; + } + + return ` +
    +

    ${escape( + label + )}

    + +
    + `; + }; + + const nestedSections = [ + renderNestedList("Images", variant.image, "No image URLs."), + renderNestedList("Fallbacks", variant.fallback, "No fallback URLs."), + renderNestedList("Services", variant.service, "No service hints."), + ].join(""); + + return ` +
  3. +
    + ${escape(variantNumber)} + ${badges.length ? `
    ${badges.join("")}
    ` : ""} +
    +
    +
    +
    MIME type
    +
    ${mimeHtml}
    +
    +
    +
    Dimensions
    +
    ${dimHtml}
    +
    +
    +
    URL
    +
    ${urlHtml}
    +
    +
    +
    Content hash
    +
    ${hashHtml}
    +
    +
    +
    + ${nestedSections} +
    +
  4. + `; + }) + .join("")}
`; + } + + renderTextTracks(tracks) { + if (!Array.isArray(tracks) || tracks.length === 0) { + return this.renderListEmpty("No caption tracks."); + } + + const escape = this.getEscapeFn(); + + return `
    ${tracks + .map((track, index) => { + const headerLabel = `Track ${index + 1}`; + const urlHtml = track.url + ? this.buildLinkMarkup(track.url) + : this.renderPlaceholder("No URL"); + const typeHtml = track.type + ? `${escape( + track.type + )}` + : this.renderPlaceholder(); + const languageHtml = track.language + ? `${escape( + track.language + )}` + : this.renderPlaceholder(); + + return ` +
  1. +
    + ${escape(headerLabel)} + ${track.language ? languageHtml : ""} +
    +
    +
    +
    URL
    +
    ${urlHtml}
    +
    +
    +
    Type
    +
    ${typeHtml}
    +
    +
    +
    Language
    +
    ${track.language ? languageHtml : this.renderPlaceholder()}
    +
    +
    +
  2. + `; + }) + .join("")}
`; + } + + renderSegments(segments) { + if (!Array.isArray(segments) || segments.length === 0) { + return this.renderListEmpty("No chapter segments."); + } + + const escape = this.getEscapeFn(); + + const formatTime = (seconds, raw) => { + if (Number.isFinite(seconds)) { + const display = this.formatDurationSeconds(seconds); + const suffix = seconds === 1 ? "second" : "seconds"; + return `${escape(display)} (${escape( + `${seconds} ${suffix}` + )})`; + } + if (raw) { + return `${escape(raw)}`; + } + return this.renderPlaceholder(); + }; + + return `
    ${segments + .map((segment, index) => { + const headerLabel = `Chapter ${index + 1}`; + const startHtml = formatTime(segment.startSeconds, segment.startRaw); + const endHtml = formatTime(segment.endSeconds, segment.endRaw); + const titleHtml = segment.title + ? `

    ${escape(segment.title)}

    ` + : this.renderPlaceholder("No title"); + const thumbnailHtml = segment.thumbnail + ? this.buildLinkMarkup(segment.thumbnail) + : this.renderPlaceholder("No thumbnail"); + + return ` +
  1. +
    ${escape(headerLabel)}
    +
    +
    +
    Start
    +
    ${startHtml}
    +
    +
    +
    End
    +
    ${endHtml}
    +
    +
    +
    Title
    +
    ${titleHtml}
    +
    +
    +
    Thumbnail
    +
    ${thumbnailHtml}
    +
    +
    +
  2. + `; + }) + .join("")}
`; + } + + renderHashtags(hashtags) { + if (!Array.isArray(hashtags) || hashtags.length === 0) { + return this.renderListEmpty("No hashtags provided."); + } + + const escape = this.getEscapeFn(); + + const chips = hashtags.map((tag) => { + const label = tag.startsWith("#") ? tag : `#${tag}`; + return `${escape( + label + )}`; + }); + + return `
${chips.join("")}
`; + } + + renderParticipants(participants) { + if (!Array.isArray(participants) || participants.length === 0) { + return this.renderListEmpty("No participants listed."); + } + + const escape = this.getEscapeFn(); + + return `
    ${participants + .map((participant, index) => { + const label = `Participant ${index + 1}`; + const displayPubkey = participant.pubkey + ? this.truncateMiddle + ? this.truncateMiddle(participant.pubkey, 48) + : participant.pubkey + : ""; + const pubkeyHtml = participant.pubkey + ? `${escape(displayPubkey)}` + : this.renderPlaceholder("No pubkey"); + const relayHtml = participant.relay + ? this.buildLinkMarkup(participant.relay, { breakAll: false }) + : this.renderPlaceholder("No relay specified"); + + return ` +
  1. +
    ${escape(label)}
    +
    +
    +
    Pubkey
    +
    ${pubkeyHtml}
    +
    +
    +
    Relay
    +
    ${relayHtml}
    +
    +
    +
  2. + `; + }) + .join("")}
`; + } + + renderReferences(references) { + if (!Array.isArray(references) || references.length === 0) { + return this.renderListEmpty("No external references."); + } + + const items = references + .map((entry) => this.buildLinkMarkup(entry)) + .filter(Boolean) + .map((markup) => `
  • ${markup}
  • `); + + if (!items.length) { + return this.renderListEmpty("No external references."); + } + + return ``; + } + extractDTagValue(tags) { if (!Array.isArray(tags)) { return "";