Add share on Nostr modal

This commit is contained in:
thePR0M3TH3AN committed 2026-01-22 22:36:56 -05:00
1 parent 8fdbb0c826
commit 012643ce12
4 files changed
+670 -4

No files matched your search

+127
View File
@@ -0,0 +1,127 @@
<!-- components/share-nostr-modal.html -->
<div
id="shareNostrModal"
class="bv-modal hidden items-start justify-center md:items-center"
role="dialog"
aria-modal="true"
aria-labelledby="shareNostrModalTitle"
aria-describedby="shareNostrModalDescription"
data-ds="new"
>
<div class="bv-modal-backdrop" data-dismiss></div>
<div class="modal-sheet max-h-modal-shell overflow-hidden flex flex-col w-full max-w-2xl" tabindex="-1">
<div class="modal-header">
<h2 id="shareNostrModalTitle" class="text-xl font-bold text-text">
Share on Nostr
</h2>
<button
id="closeShareNostrModal"
class="modal-close"
type="button"
aria-label="Close share modal"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="modal-body space-y-6 overflow-y-auto flex-1 min-h-0 custom-scrollbar p-6">
<p id="shareNostrModalDescription" class="text-sm text-muted">
Publish a short note and thumbnail preview to your preferred Nostr relays.
</p>
<div class="space-y-2">
<label for="shareNostrContent" class="block text-sm font-medium text-text">
Post content
</label>
<textarea
id="shareNostrContent"
rows="6"
class="textarea"
placeholder="Write your message"
></textarea>
</div>
<div class="space-y-3 rounded-lg border border-border bg-panel/50 p-4">
<div class="flex items-center justify-between">
<span class="text-xs font-semibold uppercase tracking-wide text-muted">
Preview
</span>
<a
id="shareNostrPreviewUrl"
class="text-xs text-accent hover:underline break-all"
href="#"
target="_blank"
rel="noopener noreferrer"
>
--
</a>
</div>
<div class="flex items-start gap-4">
<div class="h-16 w-24 rounded-md border border-border bg-surface-alt overflow-hidden flex items-center justify-center">
<img
id="shareNostrPreviewThumbnail"
class="h-full w-full object-cover hidden"
alt="Video thumbnail preview"
/>
<span
id="shareNostrPreviewFallback"
class="text-xs text-muted text-center px-2"
>
No thumbnail
</span>
</div>
<div class="flex-1 space-y-1 min-w-0">
<p id="shareNostrPreviewTitle" class="text-sm font-semibold text-text truncate">
--
</p>
<p id="shareNostrPreviewMeta" class="text-xs text-muted truncate">
--
</p>
</div>
</div>
</div>
<div class="space-y-2">
<label for="shareNostrRelays" class="block text-sm font-medium text-text">
Relays to post to
</label>
<input
id="shareNostrRelays"
type="text"
class="input"
placeholder="wss://relay.example, wss://relay.example2"
/>
<p class="text-xs text-muted">
Separate relay URLs with commas. The list defaults to your write relays.
</p>
<div id="shareNostrRelayPills" class="flex flex-wrap gap-2"></div>
</div>
</div>
<div class="modal-footer flex flex-col gap-3 border-t border-border bg-panel/30 p-4 sm:flex-row sm:items-center sm:justify-between">
<p id="shareNostrStatus" class="text-xs text-muted" role="status" aria-live="polite"></p>
<div class="flex items-center gap-3">
<button id="cancelShareNostr" type="button" class="btn-ghost">
Cancel
</button>
<button id="postShareNostr" type="button" class="btn-primary">
Post
</button>
</div>
</div>
</div>
</div>
+42 -4
View File
@@ -474,10 +474,11 @@ class Application {
this.videoModal &&
typeof this.videoModal.addEventListener === "function"
) {
this.videoModal.addEventListener("video:share-nostr", () => {
this.showStatus(
"Sharing on Nostr is not implemented yet. Coming soon!",
);
this.videoModal.addEventListener("video:share-nostr", (event) => {
this.openShareNostrModal({
video: event?.detail?.video || null,
triggerElement: event?.detail?.trigger || null,
});
});
this.videoModal.addEventListener("video:copy-cdn", (event) => {
@@ -10225,6 +10226,43 @@ class Application {
.catch(() => this.showError("Failed to copy the link."));
}
async openShareNostrModal({ video, triggerElement } = {}) {
const targetVideo =
video && typeof video === "object" ? video : this.currentVideo || null;
if (!targetVideo) {
this.showError("No video is available to share.");
return;
}
if (!this.shareNostrModal) {
devLogger.warn("[Application] Share Nostr modal is unavailable.");
this.showError("Share modal is not ready yet.");
return;
}
const shareUrl =
typeof targetVideo.shareUrl === "string" && targetVideo.shareUrl.trim()
? targetVideo.shareUrl.trim()
: this.buildShareUrlFromEventId(targetVideo.id);
const payload = {
id: targetVideo.id,
title: targetVideo.title,
pubkey: targetVideo.pubkey,
thumbnail: targetVideo.thumbnail,
shareUrl,
};
try {
await this.shareNostrModal.open({
video: payload,
triggerElement,
});
} catch (error) {
devLogger.error("[Application] Failed to open Share Nostr modal:", error);
this.showError("Unable to open the share modal.");
}
}
/**
* Copies the current video's magnet link to the clipboard.
*/
+22
View File
@@ -5,6 +5,7 @@ import { truncateMiddle, formatShortNpub } from "../utils/formatters.js";
import { showLoginRequiredToZapNotification } from "../payments/zapNotifications.js";
import { VideoModal } from "./components/VideoModal.js";
import { RevertModal } from "./components/RevertModal.js";
import { ShareNostrModal } from "./components/ShareNostrModal.js";
import initUploadModal from "./initUploadModal.js";
import initEditModal from "./initEditModal.js";
import initDeleteModal from "./initDeleteModal.js";
@@ -49,6 +50,7 @@ export default class ModalManager {
this.videoModalHandlers = {};
this.eventDetailsModal = null;
this.shareNostrModal = null;
this.zapController = null;
}
@@ -170,6 +172,14 @@ export default class ModalManager {
app.deleteModal = this.deleteModal;
app.deleteModalEvents = this.deleteModalEvents;
this.shareNostrModal = new ShareNostrModal({
removeTrackingScripts,
setGlobalModalState,
container: modalContainer,
fallbackThumbnailSrc: this.assets.fallbackThumbnailSrc,
});
app.shareNostrModal = this.shareNostrModal;
this.videoModal =
(typeof this.ui.videoModal === "function"
? this.ui.videoModal({ app })
@@ -698,6 +708,18 @@ export default class ModalManager {
}
}
if (this.shareNostrModal && typeof this.shareNostrModal.close === "function") {
try {
this.shareNostrModal.close();
} catch (error) {
devLogger.warn("[ModalManager] Failed to close share Nostr modal:", error);
}
}
this.shareNostrModal = null;
if (app.shareNostrModal) {
app.shareNostrModal = null;
}
if (this.revertModal && this.revertConfirmHandler) {
try {
this.revertModal.removeEventListener(
+479
View File
@@ -0,0 +1,479 @@
import { createModalAccessibility } from "./modalAccessibility.js";
import { DEFAULT_RELAY_URLS } from "../../nostr/toolkit.js";
import logger from "../../utils/logger.js";
import { relayManager } from "../../relayManager.js";
function normalizeRelayList(relays) {
if (!Array.isArray(relays)) {
return [];
}
const seen = new Set();
return relays
.map((relay) => (typeof relay === "string" ? relay.trim() : ""))
.filter((relay) => relay)
.filter((relay) => {
if (seen.has(relay)) {
return false;
}
seen.add(relay);
return true;
});
}
export class ShareNostrModal {
constructor({
removeTrackingScripts,
setGlobalModalState,
eventTarget,
container,
fallbackThumbnailSrc,
onPost,
onCancel,
} = {}) {
this.removeTrackingScripts =
typeof removeTrackingScripts === "function" ? removeTrackingScripts : () => {};
this.setGlobalModalState =
typeof setGlobalModalState === "function" ? setGlobalModalState : () => {};
this.eventTarget = eventTarget instanceof EventTarget ? eventTarget : new EventTarget();
this.container = container || document.getElementById("modalContainer") || null;
this.fallbackThumbnailSrc =
typeof fallbackThumbnailSrc === "string" ? fallbackThumbnailSrc : "";
this.onPost = typeof onPost === "function" ? onPost : null;
this.onCancel = typeof onCancel === "function" ? onCancel : null;
this.modal = null;
this.overlay = null;
this.modalPanel = null;
this.textarea = null;
this.previewUrl = null;
this.previewThumbnail = null;
this.previewFallback = null;
this.previewTitle = null;
this.previewMeta = null;
this.relayInput = null;
this.relayPills = null;
this.status = null;
this.postButton = null;
this.cancelButton = null;
this.closeButton = null;
this.modalAccessibility = null;
this.activeVideo = null;
this.currentRelays = [];
this.busy = false;
this.bound = false;
this.boundHandlers = {
post: (event) => this.handlePostClick(event),
cancel: (event) => this.handleCancelInteraction(event),
overlay: (event) => this.handleCancelInteraction(event),
relayInput: (event) => this.handleRelayInput(event),
relayPillClick: (event) => this.handleRelayPillClick(event),
};
}
addEventListener(type, listener, options) {
this.eventTarget.addEventListener(type, listener, options);
}
removeEventListener(type, listener, options) {
this.eventTarget.removeEventListener(type, listener, options);
}
dispatch(type, detail) {
this.eventTarget.dispatchEvent(
new CustomEvent(type, {
detail,
})
);
}
async load() {
if (this.modal) {
return this.modal;
}
const targetContainer = this.container || document.getElementById("modalContainer");
if (!targetContainer) {
throw new Error("Modal container element not found!");
}
let modal = targetContainer.querySelector("#shareNostrModal");
if (!modal) {
const response = await fetch("components/share-nostr-modal.html");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const html = await response.text();
const wrapper = document.createElement("div");
wrapper.innerHTML = html;
this.removeTrackingScripts(wrapper);
targetContainer.appendChild(wrapper);
modal = wrapper.querySelector("#shareNostrModal");
}
if (!modal) {
throw new Error("Share Nostr modal markup missing after load.");
}
this.cacheElements(modal);
this.setupModalAccessibility();
this.bindEvents();
this.reset();
return this.modal;
}
cacheElements(modal) {
this.modal = modal;
this.overlay = modal.querySelector(".bv-modal-backdrop") || null;
this.modalPanel = modal.querySelector(".modal-sheet") || modal;
this.textarea = modal.querySelector("#shareNostrContent") || null;
this.previewUrl = modal.querySelector("#shareNostrPreviewUrl") || null;
this.previewThumbnail = modal.querySelector("#shareNostrPreviewThumbnail") || null;
this.previewFallback = modal.querySelector("#shareNostrPreviewFallback") || null;
this.previewTitle = modal.querySelector("#shareNostrPreviewTitle") || null;
this.previewMeta = modal.querySelector("#shareNostrPreviewMeta") || null;
this.relayInput = modal.querySelector("#shareNostrRelays") || null;
this.relayPills = modal.querySelector("#shareNostrRelayPills") || null;
this.status = modal.querySelector("#shareNostrStatus") || null;
this.postButton = modal.querySelector("#postShareNostr") || null;
this.cancelButton = modal.querySelector("#cancelShareNostr") || null;
this.closeButton = modal.querySelector("#closeShareNostrModal") || null;
}
setupModalAccessibility() {
if (!this.modal) {
return;
}
this.modalAccessibility = createModalAccessibility({
root: this.modal,
panel: this.modalPanel || this.modal,
backdrop: this.overlay,
onRequestClose: () => this.handleCancelInteraction(),
});
}
bindEvents() {
if (this.bound || !this.modal) {
return;
}
if (this.postButton) {
this.postButton.addEventListener("click", this.boundHandlers.post);
}
if (this.cancelButton) {
this.cancelButton.addEventListener("click", this.boundHandlers.cancel);
}
if (this.closeButton) {
this.closeButton.addEventListener("click", this.boundHandlers.cancel);
}
if (this.overlay) {
this.overlay.addEventListener("click", this.boundHandlers.overlay);
}
if (this.relayInput) {
this.relayInput.addEventListener("input", this.boundHandlers.relayInput);
}
if (this.relayPills) {
this.relayPills.addEventListener("click", this.boundHandlers.relayPillClick);
}
this.bound = true;
}
reset() {
this.activeVideo = null;
this.currentRelays = [];
this.busy = false;
this.setStatus("");
this.setBusy(false);
if (this.textarea) {
this.textarea.value = "";
}
if (this.previewTitle) {
this.previewTitle.textContent = "--";
}
if (this.previewMeta) {
this.previewMeta.textContent = "--";
}
if (this.previewUrl) {
this.previewUrl.textContent = "--";
this.previewUrl.href = "#";
}
this.setThumbnail("");
this.setRelayList([]);
}
setBusy(isBusy) {
this.busy = isBusy;
const actionButtons = [this.postButton, this.cancelButton, this.closeButton];
actionButtons.forEach((button) => {
if (!(button instanceof HTMLButtonElement)) {
return;
}
button.disabled = isBusy;
});
if (this.postButton) {
this.postButton.setAttribute("aria-busy", isBusy ? "true" : "false");
const label = isBusy ? "Posting..." : "Post";
if (this.postButton.textContent !== label) {
this.postButton.textContent = label;
}
}
}
setStatus(message = "", tone = "muted") {
if (!this.status) {
return;
}
this.status.textContent = message;
const toneClassMap = {
muted: "text-muted",
success: "text-success",
warning: "text-warning",
danger: "text-danger",
};
Object.values(toneClassMap).forEach((cls) => this.status.classList.remove(cls));
const nextClass = toneClassMap[tone] || toneClassMap.muted;
this.status.classList.add(nextClass);
}
resolveDefaultRelays() {
const candidate =
relayManager && typeof relayManager.getWriteRelayUrls === "function"
? relayManager.getWriteRelayUrls()
: [];
const normalized = normalizeRelayList(candidate);
if (normalized.length) {
return normalized;
}
return normalizeRelayList(DEFAULT_RELAY_URLS);
}
parseRelayInput(value) {
if (typeof value !== "string") {
return [];
}
return normalizeRelayList(value.split(","));
}
setRelayList(relays) {
const normalized = normalizeRelayList(relays);
this.currentRelays = normalized;
if (this.relayInput) {
this.relayInput.value = normalized.join(", ");
}
this.renderRelayPills();
}
renderRelayPills() {
if (!this.relayPills) {
return;
}
this.relayPills.innerHTML = "";
if (!this.currentRelays.length) {
const placeholder = document.createElement("span");
placeholder.className = "text-xs text-muted";
placeholder.textContent = "No relays selected.";
this.relayPills.appendChild(placeholder);
return;
}
this.currentRelays.forEach((relay) => {
const pill = document.createElement("span");
pill.className =
"inline-flex items-center gap-2 rounded-full border border-border/70 bg-surface-alt px-3 py-1 text-xs text-text";
const label = document.createElement("span");
label.textContent = relay;
label.className = "truncate max-w-[200px]";
pill.appendChild(label);
const removeButton = document.createElement("button");
removeButton.type = "button";
removeButton.className =
"btn-ghost text-muted hover:text-text px-1 py-0.5 rounded-full";
removeButton.setAttribute("aria-label", `Remove ${relay}`);
removeButton.dataset.action = "remove-relay";
removeButton.dataset.relay = relay;
removeButton.innerHTML =
'<svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round" stroke-linejoin="round"/></svg>';
pill.appendChild(removeButton);
this.relayPills.appendChild(pill);
});
}
setThumbnail(url) {
const sanitized = typeof url === "string" ? url.trim() : "";
const resolved = sanitized || this.fallbackThumbnailSrc || "";
if (this.previewThumbnail) {
if (resolved) {
this.previewThumbnail.src = resolved;
this.previewThumbnail.classList.remove("hidden");
} else {
this.previewThumbnail.removeAttribute("src");
this.previewThumbnail.classList.add("hidden");
}
}
if (this.previewFallback) {
this.previewFallback.classList.toggle(
"hidden",
Boolean(resolved)
);
}
}
buildShareContent(payload) {
const title =
typeof payload?.title === "string" && payload.title.trim()
? payload.title.trim()
: "Untitled video";
const shareUrl =
typeof payload?.shareUrl === "string" ? payload.shareUrl.trim() : "";
const thumbnail =
typeof payload?.thumbnail === "string" ? payload.thumbnail.trim() : "";
return `${title} — Check out this video on bitvid 👇\n\n${shareUrl}\n\n${thumbnail}`;
}
setVideo(payload) {
this.activeVideo = payload || null;
const title =
typeof payload?.title === "string" && payload.title.trim()
? payload.title.trim()
: "Untitled video";
const shareUrl =
typeof payload?.shareUrl === "string" ? payload.shareUrl.trim() : "";
const thumbnail =
typeof payload?.thumbnail === "string" ? payload.thumbnail.trim() : "";
const pubkey =
typeof payload?.pubkey === "string" && payload.pubkey.trim()
? payload.pubkey.trim()
: "";
if (this.textarea) {
this.textarea.value = this.buildShareContent({
title,
shareUrl,
thumbnail,
});
}
if (this.previewTitle) {
this.previewTitle.textContent = title;
}
if (this.previewMeta) {
this.previewMeta.textContent = pubkey ? `Author: ${pubkey}` : "Author info unavailable";
}
if (this.previewUrl) {
const displayUrl = shareUrl || "--";
this.previewUrl.textContent = displayUrl;
this.previewUrl.href = shareUrl || "#";
this.previewUrl.classList.toggle("pointer-events-none", !shareUrl);
}
this.setThumbnail(thumbnail);
}
async open({ video, triggerElement } = {}) {
await this.load();
this.setVideo(video || {});
this.setRelayList(this.resolveDefaultRelays());
this.setStatus("");
this.modal?.classList.remove("hidden");
this.modalAccessibility?.activate({ triggerElement });
this.setGlobalModalState("shareNostr", true);
this.dispatch("share-nostr:open", { video: this.activeVideo });
}
close() {
if (!this.modal) {
return;
}
this.modal.classList.add("hidden");
this.modalAccessibility?.deactivate();
this.setGlobalModalState("shareNostr", false);
this.dispatch("share-nostr:close", { video: this.activeVideo });
this.reset();
}
handleRelayInput(event) {
if (this.busy) {
return;
}
const value = event?.target?.value || "";
this.currentRelays = this.parseRelayInput(value);
this.renderRelayPills();
}
handleRelayPillClick(event) {
const trigger = event?.target?.closest?.("[data-action='remove-relay']");
if (!trigger) {
return;
}
const relay = trigger.dataset.relay;
if (!relay) {
return;
}
this.setRelayList(this.currentRelays.filter((entry) => entry !== relay));
}
async handlePostClick(event) {
if (event?.preventDefault) {
event.preventDefault();
}
if (this.busy) {
return;
}
const content = this.textarea?.value || "";
const relays = this.currentRelays.slice();
const payload = {
video: this.activeVideo,
content,
relays,
};
this.setBusy(true);
this.setStatus("Posting to relays...", "muted");
this.dispatch("share-nostr:post", payload);
if (!this.onPost) {
logger.user.warn("Share on Nostr is not configured yet.");
this.setStatus("Posting is not configured yet.", "warning");
this.setBusy(false);
return;
}
try {
await this.onPost(payload);
this.setStatus("Posted to relays.", "success");
this.close();
} catch (error) {
logger.user.error("Failed to post to relays.");
logger.dev.warn("[ShareNostrModal] Post request failed:", error);
this.setStatus("Failed to post. Please try again.", "danger");
this.setBusy(false);
}
}
handleCancelInteraction(event) {
if (event?.preventDefault) {
event.preventDefault();
}
if (this.busy) {
return;
}
if (this.onCancel) {
this.onCancel({ video: this.activeVideo });
}
this.close();
}
}