mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
335 lines
10 KiB
JavaScript
335 lines
10 KiB
JavaScript
import { createAndPublishZapRequest, resolveZapRecipient } from "../../payments/zapRequests.js";
|
|
import { devLogger, userLogger } from "../../utils/logger.js";
|
|
|
|
function createElement(doc, tag, className, text) {
|
|
const element = doc.createElement(tag);
|
|
if (className) {
|
|
element.className = className;
|
|
}
|
|
if (typeof text === "string") {
|
|
element.textContent = text;
|
|
}
|
|
return element;
|
|
}
|
|
|
|
export function Composer({
|
|
document: doc,
|
|
placeholder = "Write a message…",
|
|
state = "idle",
|
|
privacyMode: initialPrivacyMode = "nip04",
|
|
signingAdapter = null,
|
|
zapRecipient = null,
|
|
zapConfig = null,
|
|
onSend,
|
|
onSendZap,
|
|
} = {}) {
|
|
if (!doc) {
|
|
throw new Error("Composer requires a document reference.");
|
|
}
|
|
|
|
const form = createElement(doc, "form", "dm-composer");
|
|
form.noValidate = true;
|
|
let privacyMode = initialPrivacyMode;
|
|
|
|
const label = createElement(doc, "label", "dm-composer__label", "Message");
|
|
label.setAttribute("for", "dm-composer-input");
|
|
|
|
const textarea = createElement(doc, "textarea", "dm-composer__input");
|
|
textarea.id = "dm-composer-input";
|
|
textarea.placeholder = placeholder;
|
|
textarea.rows = 2;
|
|
textarea.setAttribute("aria-label", "Message");
|
|
|
|
const hintId = "dm-composer-hint";
|
|
|
|
const actions = createElement(doc, "div", "dm-composer__actions");
|
|
const tools = createElement(doc, "div", "dm-composer__tools");
|
|
const hint = createElement(
|
|
doc,
|
|
"span",
|
|
"dm-composer__hint",
|
|
"Enter to send, Shift + Enter for newline",
|
|
);
|
|
hint.id = hintId;
|
|
textarea.setAttribute("aria-describedby", hintId);
|
|
|
|
const attachButton = createElement(
|
|
doc,
|
|
"button",
|
|
"dm-composer__attach",
|
|
"Attach",
|
|
);
|
|
attachButton.type = "button";
|
|
attachButton.setAttribute("aria-label", "Attach a file");
|
|
|
|
const privacyToggle = createElement(
|
|
doc,
|
|
"button",
|
|
"dm-composer__privacy",
|
|
"NIP-04",
|
|
);
|
|
privacyToggle.type = "button";
|
|
privacyToggle.setAttribute("aria-pressed", "false");
|
|
privacyToggle.setAttribute("aria-label", "Toggle privacy mode");
|
|
|
|
const zapToggle = createElement(doc, "button", "dm-composer__zap-toggle", "Zap");
|
|
zapToggle.type = "button";
|
|
zapToggle.setAttribute("aria-expanded", "false");
|
|
zapToggle.setAttribute("aria-label", "Open zap composer");
|
|
zapToggle.setAttribute("aria-pressed", "false");
|
|
|
|
tools.appendChild(attachButton);
|
|
tools.appendChild(privacyToggle);
|
|
tools.appendChild(zapToggle);
|
|
tools.appendChild(hint);
|
|
|
|
const button = createElement(doc, "button", "dm-composer__send", "Send");
|
|
button.type = "submit";
|
|
button.setAttribute("aria-label", "Send message");
|
|
|
|
actions.appendChild(tools);
|
|
actions.appendChild(button);
|
|
|
|
const status = createElement(doc, "div", "dm-composer__status");
|
|
if (state === "error") {
|
|
status.textContent = "Send failed. Try again.";
|
|
} else if (state === "sending") {
|
|
status.textContent = "Sending…";
|
|
}
|
|
|
|
const zapPanel = createElement(doc, "div", "dm-composer__zap-panel");
|
|
zapPanel.id = "dm-composer-zap-panel";
|
|
zapPanel.hidden = true;
|
|
zapToggle.setAttribute("aria-controls", zapPanel.id);
|
|
const zapRecipientLabel = createElement(
|
|
doc,
|
|
"span",
|
|
"dm-composer__zap-recipient",
|
|
zapRecipient?.name
|
|
? `Zap ${zapRecipient.name}`
|
|
: "Zap this contact",
|
|
);
|
|
zapPanel.appendChild(zapRecipientLabel);
|
|
|
|
const zapAddress = createElement(
|
|
doc,
|
|
"span",
|
|
"dm-composer__zap-address",
|
|
zapRecipient?.lightningAddress || "Lightning address required.",
|
|
);
|
|
zapPanel.appendChild(zapAddress);
|
|
|
|
const zapAmountLabel = createElement(doc, "label", "dm-composer__zap-label", "Amount (sats)");
|
|
zapAmountLabel.setAttribute("for", "dm-zap-amount-input");
|
|
const zapAmountInput = createElement(doc, "input", "dm-composer__zap-input");
|
|
zapAmountInput.type = "number";
|
|
zapAmountInput.min = "1";
|
|
zapAmountInput.step = "1";
|
|
zapAmountInput.inputMode = "numeric";
|
|
zapAmountInput.id = "dm-zap-amount-input";
|
|
zapAmountInput.placeholder = "100";
|
|
zapAmountInput.setAttribute("aria-label", "Zap amount in sats");
|
|
zapPanel.appendChild(zapAmountLabel);
|
|
zapPanel.appendChild(zapAmountInput);
|
|
|
|
const zapNoteLabel = createElement(doc, "label", "dm-composer__zap-label", "Zap note");
|
|
zapNoteLabel.setAttribute("for", "dm-zap-note-input");
|
|
const zapNoteInput = createElement(doc, "input", "dm-composer__zap-input");
|
|
zapNoteInput.type = "text";
|
|
zapNoteInput.id = "dm-zap-note-input";
|
|
zapNoteInput.placeholder = "Optional note for this zap";
|
|
zapNoteInput.setAttribute("aria-label", "Zap note");
|
|
zapPanel.appendChild(zapNoteLabel);
|
|
zapPanel.appendChild(zapNoteInput);
|
|
|
|
const zapActions = createElement(doc, "div", "dm-composer__zap-actions");
|
|
const zapSendButton = createElement(doc, "button", "dm-composer__zap-send", "Send zap");
|
|
zapSendButton.type = "button";
|
|
zapSendButton.setAttribute("aria-label", "Send zap request");
|
|
zapActions.appendChild(zapSendButton);
|
|
zapPanel.appendChild(zapActions);
|
|
|
|
const zapStatus = createElement(doc, "div", "dm-composer__zap-status");
|
|
zapPanel.appendChild(zapStatus);
|
|
|
|
form.appendChild(label);
|
|
form.appendChild(textarea);
|
|
form.appendChild(zapPanel);
|
|
form.appendChild(actions);
|
|
form.appendChild(status);
|
|
|
|
const handleSubmit = async () => {
|
|
if (typeof onSend !== "function") {
|
|
return;
|
|
}
|
|
|
|
const payload = { privacyMode, attachments: [] };
|
|
if (signingAdapter) {
|
|
try {
|
|
if (typeof signingAdapter.getPubkey === "function") {
|
|
payload.pubkey = await signingAdapter.getPubkey();
|
|
}
|
|
if (typeof signingAdapter.getDisplayName === "function") {
|
|
payload.displayName = await signingAdapter.getDisplayName();
|
|
}
|
|
if (typeof signingAdapter.signMessage === "function") {
|
|
payload.signature = await signingAdapter.signMessage(textarea.value);
|
|
}
|
|
} catch (error) {
|
|
payload.signingError =
|
|
error instanceof Error ? error.message : "Signing failed.";
|
|
}
|
|
}
|
|
|
|
onSend(textarea.value, payload);
|
|
};
|
|
|
|
let zapOpen = false;
|
|
let resolvedRecipient = null;
|
|
|
|
const setZapStatus = (message, variant = "info") => {
|
|
zapStatus.textContent = message || "";
|
|
zapStatus.dataset.state = variant;
|
|
};
|
|
|
|
const resolveRecipient = async () => {
|
|
if (!zapRecipient?.lightningAddress) {
|
|
setZapStatus("Add a Lightning address to enable zaps.", "error");
|
|
return null;
|
|
}
|
|
setZapStatus("Resolving LNURL…", "loading");
|
|
try {
|
|
const resolver =
|
|
typeof zapConfig?.resolveRecipient === "function"
|
|
? zapConfig.resolveRecipient
|
|
: resolveZapRecipient;
|
|
resolvedRecipient = await resolver(zapRecipient.lightningAddress, {
|
|
fetcher: zapConfig?.fetcher,
|
|
});
|
|
if (
|
|
!resolvedRecipient?.metadata?.allowsNostr &&
|
|
!resolvedRecipient?.metadata?.nostrPubkey
|
|
) {
|
|
setZapStatus("Recipient LNURL does not support Nostr zaps.", "error");
|
|
resolvedRecipient = null;
|
|
return null;
|
|
}
|
|
setZapStatus("LNURL ready for zaps.", "success");
|
|
return resolvedRecipient;
|
|
} catch (error) {
|
|
devLogger.warn("[dm] Failed to resolve LNURL for zap recipient.", error);
|
|
setZapStatus(
|
|
error instanceof Error ? error.message : "Failed to resolve LNURL.",
|
|
"error",
|
|
);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
form.addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
void handleSubmit();
|
|
});
|
|
|
|
const updatePrivacyLabel = () => {
|
|
const normalized =
|
|
typeof privacyMode === "string" ? privacyMode.trim().toLowerCase() : "";
|
|
const isNip17 = normalized === "nip17" || normalized === "private";
|
|
privacyToggle.textContent = isNip17 ? "NIP-17" : "NIP-04";
|
|
privacyToggle.setAttribute("aria-pressed", isNip17 ? "true" : "false");
|
|
privacyToggle.title = isNip17
|
|
? "NIP-17 gift-wraps your DM so relays only see the wrapper and relay hints."
|
|
: "NIP-04 sends a direct encrypted DM; relays can still see sender and recipient metadata.";
|
|
};
|
|
|
|
updatePrivacyLabel();
|
|
|
|
privacyToggle.addEventListener("click", () => {
|
|
const normalized =
|
|
typeof privacyMode === "string" ? privacyMode.trim().toLowerCase() : "";
|
|
const isNip17 = normalized === "nip17" || normalized === "private";
|
|
privacyMode = isNip17 ? "nip04" : "nip17";
|
|
updatePrivacyLabel();
|
|
});
|
|
|
|
zapToggle.addEventListener("click", () => {
|
|
zapOpen = !zapOpen;
|
|
zapPanel.hidden = !zapOpen;
|
|
zapToggle.setAttribute("aria-expanded", zapOpen ? "true" : "false");
|
|
zapToggle.setAttribute("aria-pressed", zapOpen ? "true" : "false");
|
|
if (zapOpen) {
|
|
void resolveRecipient();
|
|
} else {
|
|
setZapStatus("");
|
|
}
|
|
});
|
|
|
|
zapSendButton.addEventListener("click", async () => {
|
|
const amount = Number(zapAmountInput.value);
|
|
if (!Number.isFinite(amount) || amount <= 0) {
|
|
setZapStatus("Enter a zap amount greater than zero.", "error");
|
|
return;
|
|
}
|
|
|
|
const resolved = resolvedRecipient || (await resolveRecipient());
|
|
if (!resolved) {
|
|
return;
|
|
}
|
|
|
|
const comment = zapNoteInput.value.trim();
|
|
const payload = {
|
|
amountSats: Math.round(amount),
|
|
comment,
|
|
recipient: zapRecipient,
|
|
resolved,
|
|
};
|
|
|
|
if (typeof onSendZap === "function") {
|
|
try {
|
|
await onSendZap(payload);
|
|
setZapStatus("Zap request sent. Awaiting receipt…", "success");
|
|
} catch (error) {
|
|
userLogger.error("[dm] Zap request failed.", error);
|
|
setZapStatus(
|
|
error instanceof Error ? error.message : "Zap request failed.",
|
|
"error",
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (zapConfig?.signer) {
|
|
try {
|
|
await createAndPublishZapRequest({
|
|
address: zapRecipient?.lightningAddress,
|
|
recipientPubkey: zapRecipient?.pubkey,
|
|
relays: zapRecipient?.relayHints || zapConfig?.relays,
|
|
amountSats: Math.round(amount),
|
|
comment,
|
|
signer: zapConfig.signer,
|
|
pool: zapConfig.pool,
|
|
fetcher: zapConfig.fetcher,
|
|
});
|
|
setZapStatus("Zap request published. Awaiting receipt…", "success");
|
|
} catch (error) {
|
|
userLogger.error("[dm] Zap publish failed.", error);
|
|
setZapStatus(
|
|
error instanceof Error ? error.message : "Zap request failed.",
|
|
"error",
|
|
);
|
|
}
|
|
} else {
|
|
setZapStatus("Connect a Nostr signer to send zap requests.", "error");
|
|
}
|
|
});
|
|
|
|
textarea.addEventListener("keydown", (event) => {
|
|
if (event.key === "Enter" && !event.shiftKey) {
|
|
event.preventDefault();
|
|
form.requestSubmit();
|
|
}
|
|
});
|
|
|
|
return form;
|
|
}
|