const messageIdentifierPrefix = `mintership-forum-message`; const messageAttachmentIdentifierPrefix = `mintership-forum-attachment`; // NOTE - SET adminGroups in QortalApi.js to enable admin access to forum for specific groups. Minter Admins will be fetched automatically. let replyToMessageIdentifier = null; let latestMessageIdentifiers = {}; // To keep track of the latest message in each room let currentPage = 0; // Track current pagination page let existingIdentifiers = new Set(); // Keep track of existing identifiers to not pull them more than once. let messagesById = {} let messageOrder =[] const MAX_MESSAGES = 2000 // Key = message.identifier // Value = { ...the message object with timestamp, name, content, etc. } // If there is a previous latest message identifiers, use them. Otherwise, use an empty. const storeMessageInMap = (msg) => { if (!msg?.identifier || !msg || !msg?.timestamp) return messagesById[msg.identifier] = msg // We will keep an array 'messageOrder' to store the messages and limit the size they take messageOrder.push({ identifier: msg.identifier, timestamp: msg.timestamp }) messageOrder.sort((a, b) => a.timestamp - b.timestamp); while (messageOrder.length > MAX_MESSAGES) { // Remove oldest from the front const oldest = messageOrder.shift(); // Delete from the map as well delete messagesById[oldest.identifier]; } } function saveMessagesToLocalStorage() { try { const data = { messagesById, messageOrder }; localStorage.setItem("forumMessages", JSON.stringify(data)); console.log("Saved messages to localStorage. Count:", messageOrder.length); } catch (error) { console.error("Error saving to localStorage:", error); } } function loadMessagesFromLocalStorage() { try { const stored = localStorage.getItem("forumMessages"); if (!stored) { console.log("No saved messages in localStorage."); return; } const parsed = JSON.parse(stored); if (parsed.messagesById && parsed.messageOrder) { messagesById = parsed.messagesById; messageOrder = parsed.messageOrder; console.log(`Loaded ${messageOrder.length} messages from localStorage.`); } } catch (error) { console.error("Error loading messages from localStorage:", error); } } if (localStorage.getItem("latestMessageIdentifiers")) { latestMessageIdentifiers = JSON.parse(localStorage.getItem("latestMessageIdentifiers")); } document.addEventListener("DOMContentLoaded", async () => { console.log("DOMContentLoaded fired!"); // --- GENERAL LINKS (MINTERSHIP-FORUM and MINTER-BOARD) --- const mintershipForumLinks = document.querySelectorAll('a[href="MINTERSHIP-FORUM"]'); mintershipForumLinks.forEach(link => { link.addEventListener('click', async (event) => { event.preventDefault(); if (!userState.isLoggedIn) { await login(); } await loadForumPage(); loadRoomContent("general"); startPollingForNewMessages(); }); }); const minterBoardLinks = document.querySelectorAll('a[href="MINTER-BOARD"], a[href="MINTERS"]'); minterBoardLinks.forEach(link => { link.addEventListener("click", async (event) => { event.preventDefault(); if (!userState.isLoggedIn) { await login(); } if (typeof loadMinterBoardPage === "undefined") { console.log("loadMinterBoardPage not found, loading script dynamically..."); await loadScript("./assets/js/MinterBoard.js"); } await loadMinterBoardPage(); }); }); // --- ADMIN CHECK --- await verifyUserIsAdmin(); if (userState.isAdmin && (localStorage.getItem('savedAdminData'))) { console.log('saved admin data found (Q-Mintership.js), loading...') const adminData = localStorage.getItem('savedAdminData') const parsedAdminData = JSON.parse(adminData) if (!adminPublicKeys || adminPublicKeys.length === 0 || !Array.isArray(adminPublicKeys)) { console.log('no adminPublicKey variable data found and/or data did not pass checks, using fetched localStorage data...',adminPublicKeys) if (parsedAdminData.publicKeys.length === 0 || !parsedAdminData.publicKeys || !Array.isArray(parsedAdminData.publicKeys)) { console.log('loaded data from localStorage also did not pass checks... fetching from API...',parsedAdminData.publicKeys) adminPublicKeys = await fetchAdminGroupsMembersPublicKeys() } else { adminPublicKeys = parsedAdminData.publicKeys } } } if (userState.isAdmin) { console.log(`User is an Admin. Admin-specific buttons will remain visible.`); // DATA-BOARD Links for Admins const minterDataBoardLinks = document.querySelectorAll('a[href="ADMINBOARD"]'); minterDataBoardLinks.forEach(link => { link.addEventListener("click", async (event) => { event.preventDefault(); if (!userState.isLoggedIn) { await login(); } if (typeof loadAdminBoardPage === "undefined") { console.log("loadAdminBoardPage function not found, loading script dynamically..."); await loadScript("./assets/js/AdminBoard.js"); } await loadAdminBoardPage(); }); }); // TOOLS Links for Admins const toolsLinks = document.querySelectorAll('a[href="TOOLS"]'); toolsLinks.forEach(link => { link.addEventListener('click', async (event) => { event.preventDefault(); if (!userState.isLoggedIn) { await login(); } if (typeof loadMinterAdminToolsPage === "undefined") { console.log("loadMinterAdminToolsPage function not found, loading script dynamically..."); await loadScript("./assets/js/AdminTools.js"); } await loadMinterAdminToolsPage(); }); }); } else { console.log("User is NOT an Admin. Removing admin-specific links."); // Remove all admin-specific links and their parents const toolsLinks = document.querySelectorAll('a[href="TOOLS"], a[href="ADMINBOARD"]'); toolsLinks.forEach(link => { const buttonParent = link.closest('button'); if (buttonParent) buttonParent.remove(); const cardParent = link.closest('.item.features-image'); if (cardParent) cardParent.remove(); link.remove(); }); // Center the remaining card if it exists const remainingCard = document.querySelector('.features7 .row .item.features-image'); if (remainingCard) { remainingCard.classList.remove('col-lg-6', 'col-md-6'); remainingCard.classList.add('col-12', 'text-center'); } } console.log("All DOMContentLoaded tasks completed."); }); async function loadScript(src) { return new Promise((resolve, reject) => { const script = document.createElement("script"); script.src = src; script.onload = resolve; script.onerror = reject; document.head.appendChild(script); }); } // Main load function to clear existing HTML and load the forum page ----------------------------------------------------- const loadForumPage = async () => { // remove everything that isn't the menu from the body to use js to generate page content. const bodyChildren = document.body.children; for (let i = bodyChildren.length - 1; i >= 0; i--) { const child = bodyChildren[i]; if (!child.classList.contains('menu')) { child.remove(); } } if ((typeof userState.isAdmin === 'undefined') || (!userState.isAdmin)){ try { // Fetch and verify the admin status asynchronously userState.isAdmin = await verifyUserIsAdmin(); } catch (error) { console.error('Error verifying admin status:', error); userState.isAdmin = false; // Default to non-admin if there's an issue } } const avatarUrl = `/arbitrary/THUMBNAIL/${userState.accountName}/qortal_avatar`; const isAdmin = userState.isAdmin; // Create the forum layout, including a header, sub-menu, and keeping the original background image: style="background-image: url('/assets/images/background.jpg');"> const mainContent = document.createElement('div') mainContent.innerHTML = `
No messages found. Be the first to post!
` } return true } return false } const getCurrentMostRecentMessage = (room) => { return latestMessageIdentifiers[room]?.latestTimestamp ? latestMessageIdentifiers[room] : null } // 1) Convert fetchAllMessages to fully async const fetchAllMessages = async (response, service, room) => { // Instead of returning Promise.all(...) directly, // we explicitly map each resource to a try/catch block. const messages = await Promise.all( response.map(async (resource) => { try { const msg = await fetchFullMessage(resource, service, room) return msg; // This might be null if you do that check in fetchFullMessage } catch (err) { console.error(`Skipping resource ${resource.identifier} due to error:`, err) // Return null so it doesn't break everything return null } }) ) // Filter out any that are null/undefined (missing or errored) return messages.filter(Boolean) } // 2) fetchFullMessage is already async. We keep it async/await-based const fetchFullMessage = async (resource, service, room) => { // 1) Skip if we already have it in memory if (messagesById[resource.identifier]) { // Possibly also check if the local data is "up to date," //TODO when adding 'edit' ability to messages, will also need to verify timestamp in saved data. // but if you trust your local data, skip the fetch entirely. console.log(`Skipping fetch. Found in local store: ${resource.identifier}`) return messagesById[resource.identifier] } try { // Skip if already displayed if (existingIdentifiers.has(resource.identifier)) { return null } console.log(`Fetching message with identifier: ${resource.identifier}`) const messageResponse = await qortalRequest({ action: "FETCH_QDN_RESOURCE", name: resource.name, service, identifier: resource.identifier, ...(room === "admins" ? { encoding: "base64" } : {}), }) const timestamp = resource.updated || resource.created const formattedTimestamp = await timestampToHumanReadableDate(timestamp) const messageObject = await processMessageObject(messageResponse, room) const builtMsg = { name: resource.name, content: messageObject?.messageHtml || "Message content missing", date: formattedTimestamp, identifier: resource.identifier, replyTo: messageObject?.replyTo || null, timestamp, attachments: messageObject?.attachments || [], } // 3) Store it in the map so we skip future fetches storeMessageInMap(builtMsg) return builtMsg } catch (error) { console.error(`Failed to fetch message ${resource.identifier}: ${error.message}`) return { name: resource.name, content: "Error loading message", date: "Unknown", identifier: resource.identifier, replyTo: null, timestamp: resource.updated || resource.created, attachments: [], } } } const fetchReplyData = async (service, name, identifier, room, replyTimestamp) => { try { console.log(`Fetching message with identifier: ${identifier}`) const messageResponse = await qortalRequest({ action: "FETCH_QDN_RESOURCE", name, service, identifier, ...(room === "admins" ? { encoding: "base64" } : {}), }) console.log('reply response',messageResponse) const messageObject = await processMessageObject(messageResponse, room) console.log('reply message object',messageObject) const formattedTimestamp = await timestampToHumanReadableDate(replyTimestamp) return { name, content: messageObject?.messageHtml || "Message content missing", date: formattedTimestamp, identifier, replyTo: messageObject?.replyTo || null, timestamp: replyTimestamp, attachments: messageObject?.attachments || [], } } catch (error) { console.error(`Failed to fetch message ${identifier}: ${error.message}`) return { name, content: "Error loading message", date: "Unknown", identifier, replyTo: null, timestamp: null, attachments: [], } } } const processMessageObject = async (messageResponse, room) => { if (room !== "admins") { return messageResponse; } try { const decryptedData = await decryptAndParseObject(messageResponse); return decryptedData } catch (error) { console.error(`Failed to decrypt admin message: ${error.message}`); return null; } }; const renderNewMessages = async (fetchMessages, existingIdentifiers, messagesContainer, room, mostRecentMessage) => { let firstNewMessageIdentifier = null let updatedMostRecentMessage = mostRecentMessage for (const message of fetchMessages) { if (message && !existingIdentifiers.has(message.identifier)) { const isNewMessage = isMessageNew(message, mostRecentMessage) if (isNewMessage && !firstNewMessageIdentifier) { firstNewMessageIdentifier = message.identifier } const messageHTML = await buildMessageHTML(message, fetchMessages, room, isNewMessage) messagesContainer.insertAdjacentHTML('beforeend', messageHTML) if (!updatedMostRecentMessage || new Date(message.timestamp) > new Date(updatedMostRecentMessage?.latestTimestamp || 0)) { updatedMostRecentMessage = { latestIdentifier: message.identifier, latestTimestamp: message.timestamp, } } existingIdentifiers.add(message.identifier) } } return { firstNewMessageIdentifier, updatedMostRecentMessage } } const isMessageNew = (message, mostRecentMessage) => { return !mostRecentMessage || new Date(message.timestamp) > new Date(mostRecentMessage?.latestTimestamp) } const buildMessageHTML = async (message, fetchMessages, room, isNewMessage) => { const replyHtml = await buildReplyHtml(message, room) const attachmentHtml = await buildAttachmentHtml(message, room) const avatarUrl = `/arbitrary/THUMBNAIL/${message.name}/qortal_avatar` return ` ` } const buildReplyHtml = async (message, room) => { // 1) If no replyTo, skip if (!message.replyTo) return "" // 2) Decide which QDN service for this room const replyService = (room === "admins") ? "MAIL_PRIVATE" : "BLOG_POST" const replyIdentifier = message.replyTo // 3) Check if we already have a *saved* message const savedRepliedToMessage = messagesById[replyIdentifier] console.log("savedRepliedToMessage", savedRepliedToMessage) // 4) If we do, try to process/decrypt it if (savedRepliedToMessage) { if (savedRepliedToMessage) { // We successfully processed the cached message console.log("Using saved message data for reply:", savedRepliedToMessage) return ` ` } else { // The cached message is invalid console.log("Saved message found but processMessageObject returned null. Falling back...") } } // 5) Fallback approach: If we don't have it in memory OR the cached version was invalid try { const replyData = await searchSimple(replyService, replyIdentifier, "", 1) if (!replyData || !replyData.name) { console.log("No data found via searchSimple. Skipping reply rendering.") return "" } // We'll use replyData to fetch the actual message from QDN const replyName = replyData.name const replyTimestamp = replyData.updated || replyData.created console.log("message not found in workable form, using searchSimple result =>", replyData) // This fetches and decrypts the actual message const repliedMessage = await fetchReplyData(replyService, replyName, replyIdentifier, room, replyTimestamp) if (!repliedMessage) return "" // Now store the final message in the map for next time storeMessageInMap(repliedMessage) // Return final HTML return ` ` } catch (error) { throw error } } const buildAttachmentHtml = async (message, room) => { if (!message.attachments || message.attachments.length === 0) { return "" } // Map over attachments -> array of Promises const attachmentsHtmlPromises = message.attachments.map(attachment => buildSingleAttachmentHtml(attachment, room) ) // Wait for all Promises to resolve -> array of HTML strings const attachmentsHtmlArray = await Promise.all(attachmentsHtmlPromises) // Join them into a single string return attachmentsHtmlArray.join("") } const buildSingleAttachmentHtml = async (attachment, room) => { if (room !== "admins" && attachment.mimeType && attachment.mimeType.startsWith('image/')) { const imageUrl = `/arbitrary/${attachment.service}/${attachment.name}/${attachment.identifier}` return ` ` } else if (room === "admins" && attachment.mimeType && attachment.mimeType.startsWith('image/')) { // const imageUrl = `/arbitrary/${attachment.service}/${attachment.name}/${attachment.identifier}`; const decryptedBase64 = await fetchEncryptedImageBase64(attachment.service, attachment.name, attachment.identifier, attachment.mimeType) const dataUrl = `data:image/${attachment.mimeType};base64,${decryptedBase64}` return ` ` } else { return ` ` } } const scrollToNewMessages = (firstNewMessageIdentifier) => { const newMessageElement = document.querySelector(`.message-item[data-identifier="${firstNewMessageIdentifier}"]`) if (newMessageElement) { newMessageElement.scrollIntoView({ behavior: 'smooth', block: 'center' }) } } const updateLatestMessageIdentifiers = (room, mostRecentMessage) => { latestMessageIdentifiers[room] = mostRecentMessage localStorage.setItem("latestMessageIdentifiers", JSON.stringify(latestMessageIdentifiers)) } const handleReplyLogic = (fetchMessages) => { const replyButtons = document.querySelectorAll(".reply-button") replyButtons.forEach(button => { button.addEventListener("click", () => { const replyToMessageIdentifier = button.dataset.messageIdentifier const repliedMessage = fetchMessages.find(m => m && m.identifier === replyToMessageIdentifier) if (repliedMessage) { showReplyPreview(repliedMessage) } }) }) } const showReplyPreview = (repliedMessage) => { replyToMessageIdentifier = repliedMessage.identifier const replyContainer = document.createElement("div") replyContainer.className = "reply-container" replyContainer.innerHTML = ` ` if (!document.querySelector(".reply-container")) { const messageInputSection = document.querySelector(".message-input-section") if (messageInputSection) { messageInputSection.insertBefore(replyContainer, messageInputSection.firstChild) document.getElementById("cancel-reply").addEventListener("click", () => { replyToMessageIdentifier = null replyContainer.remove() }) } } const messageInputSection = document.querySelector(".message-input-section") const editor = document.querySelector(".ql-editor") if (messageInputSection) { messageInputSection.scrollIntoView({ behavior: 'smooth', block: 'center' }) } if (editor) { editor.focus() } } const updatePaginationControls = async (room, limit) => { const totalMessages = room === "admins" ? await searchAllCountOnly(`${messageIdentifierPrefix}-${room}-e`, room) : await searchAllCountOnly(`${messageIdentifierPrefix}-${room}`, room) renderPaginationControls(room, totalMessages, limit) } // Polling function to check for new messages without clearing existing ones function startPollingForNewMessages() { setInterval(async () => { const activeRoom = document.querySelector('.room-title')?.innerText.toLowerCase().split(" ")[0] if (activeRoom) { await loadMessagesFromQDN(activeRoom, currentPage, true) } }, 40000) }