@getalby/lightning-messageboard
Version:
A web component for a lightning messageboard powered by NWC
336 lines (335 loc) • 13.4 kB
JavaScript
import { nwc } from "@getalby/sdk";
import { launchPaymentModal } from "@getalby/bitcoin-connect";
import { template } from "./template";
import { escapeHTML, formatNumber, showToast } from "./helper";
/**
* LightningMessageboard Web Component
*
* A customizable web component for displaying a Lightning Network-powered messageboard
* where users can pay to post messages.
*/
export class LightningMessageboard extends HTMLElement {
// Observed attributes
static get observedAttributes() {
return ["nwc-url", "theme"];
}
constructor() {
super();
this.messages = [];
this.messageText = "";
this.senderName = "";
this.amount = "";
this.topAmount = 1000;
this.shadow = this.attachShadow({ mode: "open" });
this.shadow.appendChild(template.content.cloneNode(true));
this.initializeElements();
this.setupEventListeners();
}
// Component lifecycle methods
connectedCallback() {
// Initialize when component is connected to the DOM
this.applyTheme();
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue)
return;
switch (name) {
case "nwc-url":
this.initializeNWC();
break;
case "theme":
this.applyTheme();
break;
}
}
// Initialize DOM element references
initializeElements() {
this.messageList = this.shadow.getElementById("message-list");
this.toggleButton = this.shadow.getElementById("toggle-button");
this.cardContent = this.shadow.getElementById("card-content");
this.messageInput = this.shadow.getElementById("message-input");
this.messageForm = this.shadow.getElementById("message-form");
this.dialog = this.shadow.getElementById("dialog");
this.senderNameInput = this.shadow.getElementById("sender-name");
this.amountInput = this.shadow.getElementById("amount");
this.messageTextArea = this.shadow.getElementById("message-text");
this.topAmountButton = this.shadow.getElementById("top-amount");
this.cancelButton = this.shadow.getElementById("cancel-button");
this.confirmButton = this.shadow.getElementById("confirm-button");
this.confirmSpinner = this.shadow.getElementById("confirm-spinner");
this.loadingSpinner = this.shadow.getElementById("loading-spinner");
this.centeredLoadingSpinner = this.shadow.getElementById("centered-loading-spinner");
}
// Set up event listeners
setupEventListeners() {
var _a, _b, _c, _d, _e, _f, _g, _h;
// Message form
(_a = this.messageForm) === null || _a === void 0 ? void 0 : _a.addEventListener("submit", (e) => this.handleSubmitOpenDialog(e));
// Payment form
(_b = this.shadow
.getElementById("payment-form")) === null || _b === void 0 ? void 0 : _b.addEventListener("submit", (e) => this.handleSubmitPayment(e));
// Dialog cancel
(_c = this.cancelButton) === null || _c === void 0 ? void 0 : _c.addEventListener("click", () => this.setDialogOpen(false));
// Top amount button
(_d = this.topAmountButton) === null || _d === void 0 ? void 0 : _d.addEventListener("click", () => {
if (this.amountInput) {
this.amountInput.value = this.topAmount.toString();
this.amount = this.topAmount.toString();
}
});
// Form input changes
(_e = this.messageInput) === null || _e === void 0 ? void 0 : _e.addEventListener("input", (e) => {
this.messageText = e.target.value;
});
(_f = this.senderNameInput) === null || _f === void 0 ? void 0 : _f.addEventListener("input", (e) => {
this.senderName = e.target.value;
});
(_g = this.amountInput) === null || _g === void 0 ? void 0 : _g.addEventListener("input", (e) => {
this.amount = e.target.value;
});
(_h = this.messageTextArea) === null || _h === void 0 ? void 0 : _h.addEventListener("input", (e) => {
this.messageText = e.target.value;
if (this.messageInput) {
this.messageInput.value = this.messageText;
}
});
}
// Initialize NWC client with the provided URL
initializeNWC() {
const nwcUrl = this.getAttribute("nwc-url");
if (!nwcUrl) {
return;
}
this.nwcClient = new nwc.NWCClient({
nostrWalletConnectUrl: nwcUrl,
});
// Load messages if the component is open
this.loadMessages();
}
// Apply theme colors
applyTheme() {
const theme = this.getAttribute("theme");
if (theme) {
try {
const themeObj = JSON.parse(theme);
for (const [key, value] of Object.entries(themeObj)) {
this.style.setProperty(`--lmb-${key}`, value);
}
}
catch (e) {
console.error("Invalid theme format", e);
}
}
}
// Show/hide the payment dialog
setDialogOpen(open) {
if (this.dialog) {
this.dialog.classList.toggle("hidden", !open);
}
if (open) {
// When opening the dialog, copy the message from input
if (this.messageTextArea && this.messageInput) {
this.messageTextArea.value = this.messageInput.value;
this.messageText = this.messageInput.value;
}
// Focus the name input
setTimeout(() => {
var _a;
(_a = this.senderNameInput) === null || _a === void 0 ? void 0 : _a.focus();
}, 100);
}
}
// Handle the message form submission (opens the dialog)
handleSubmitOpenDialog(e) {
e.preventDefault();
this.setDialogOpen(true);
}
// Handle the payment form submission
async handleSubmitPayment(e) {
e.preventDefault();
// Validate amount
if (Number(this.amount) < 1000) {
if (this.toastTimeout) {
window.clearTimeout(this.toastTimeout);
}
this.toastTimeout = showToast(this.shadow, "Amount too low", "Minimum payment is 1000 sats", "error");
return;
}
this.setSubmitting(true);
try {
// Generate the invoice
await this.processPayment();
}
catch (error) {
console.error(error);
if (this.toastTimeout) {
window.clearTimeout(this.toastTimeout);
}
this.toastTimeout = showToast(this.shadow, "Error", "Something went wrong: " + error, "error");
}
this.setSubmitting(false);
}
// Process payment using NWC
async processPayment() {
if (!this.nwcClient) {
throw new Error("NWC client not initialized");
}
const amountMsat = Number(this.amount) * 1000;
const transaction = await this.nwcClient.makeInvoice({
amount: amountMsat,
description: this.messageText,
metadata: {
payer_data: {
name: this.senderName,
},
},
});
// set bitcoin connect color (basic)
const theme = this.getAttribute("theme");
if (theme) {
try {
const themeObj = JSON.parse(theme);
if (themeObj["primary-color"]) {
window.document.body.style.setProperty(`--bc-color-brand`, themeObj["primary-color"]);
}
}
catch (e) {
console.error("Invalid theme format", e);
}
}
const { setPaid } = launchPaymentModal({
invoice: transaction.invoice,
});
const interval = setInterval(async () => {
var _a;
const updatedTransaction = await ((_a = this.nwcClient) === null || _a === void 0 ? void 0 : _a.lookupInvoice({
payment_hash: transaction.payment_hash,
}));
if (updatedTransaction === null || updatedTransaction === void 0 ? void 0 : updatedTransaction.preimage) {
clearInterval(interval);
setPaid({
preimage: updatedTransaction.preimage,
});
// Clear message field and reload messages
if (this.messageInput) {
this.messageInput.value = "";
this.messageText = "";
}
await this.loadMessages();
if (this.toastTimeout) {
window.clearTimeout(this.toastTimeout);
}
this.toastTimeout = showToast(this.shadow, "Success", "Message sent successfully");
this.setDialogOpen(false);
}
}, 1000);
}
// Load messages from NWC
async loadMessages() {
if (!this.nwcClient) {
return;
}
this.setLoading(true);
try {
let offset = 0;
const _messages = [];
// Fetch transactions in batches
while (true) {
try {
const transactions = await this.nwcClient.listTransactions({
offset,
limit: 10,
});
if (transactions.transactions.length === 0) {
break;
}
_messages.push(...transactions.transactions.map((transaction) => {
var _a, _b;
return ({
message: transaction.description,
name: (_b = (_a = transaction.metadata) === null || _a === void 0 ? void 0 : _a.payer_data) === null || _b === void 0 ? void 0 : _b.name,
amount: Math.floor(transaction.amount / 1000),
});
}));
offset += transactions.transactions.length;
}
catch (error) {
console.error(error);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
// Sort messages by amount (highest first)
_messages.sort((a, b) => b.amount - a.amount);
this.messages = _messages;
this.renderMessages();
}
catch (error) {
console.error("Failed to load messages", error);
this.messages = [];
this.renderMessages();
// Show error toast
if (this.toastTimeout) {
window.clearTimeout(this.toastTimeout);
}
this.toastTimeout = showToast(this.shadow, "Error", "Failed to load messages: " + error, "error");
}
this.setLoading(false);
}
// Render messages to the DOM
renderMessages() {
if (!this.messageList)
return;
// Update top amount for the "Top" button
this.topAmount = Math.max(1000, ...(this.messages.map((message) => message.amount + 1) || []));
// Clear the message list
this.messageList.innerHTML = "";
// Add each message
for (let i = 0; i < this.messages.length; i++) {
const message = this.messages[i];
const messageEl = document.createElement("div");
messageEl.innerHTML = `
<div class="card-header">
<h3 class="card-title break-word">${escapeHTML(message.message)}</h3>
</div>
<div class="card-footer">
<span class="text-muted text-sm">by ${message.name ? escapeHTML(message.name) : "Anonymous"}</span>
<div>
<span class="badge">
<svg class="zap-icon" viewBox="0 0 24 24">
<polyline points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polyline>
</svg>
${formatNumber(message.amount)}
</span>
</div>
</div>
${i !== this.messages.length - 1 ? '<div class="separator"></div>' : ""}
`;
this.messageList.appendChild(messageEl);
}
// Show empty state if no messages
if (this.messages.length === 0) {
const emptyEl = document.createElement("div");
emptyEl.className = "text-muted empty-message";
emptyEl.innerHTML = "No messages yet. Be the first to post!";
this.messageList.appendChild(emptyEl);
}
}
// Set loading state
setLoading(isLoading) {
if (this.loadingSpinner) {
this.loadingSpinner.classList.toggle("hidden", !isLoading);
}
if (this.centeredLoadingSpinner) {
this.centeredLoadingSpinner.classList.toggle("hidden", !isLoading);
}
}
// Set submitting state
setSubmitting(isSubmitting) {
if (this.confirmButton) {
this.confirmButton.disabled = isSubmitting;
}
if (this.confirmSpinner) {
this.confirmSpinner.classList.toggle("hidden", !isSubmitting);
}
}
}