const messageIdentifierPrefix = `mintership-forum-message` const messageAttachmentIdentifierPrefix = `mintership-forum-attachment` // Kakashi Note: Version label rendering is owned by index.html so release changes happen in one centralized location. // 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!") createScrollToTopButton() // --- 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() createScrollToTopButton() }) }) 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() }) }) const addRemoveAdminLinks = document.querySelectorAll( 'a[href="ADDREMOVEADMIN"]' ) addRemoveAdminLinks.forEach((link) => { link.addEventListener("click", async (event) => { event.preventDefault() // Possibly require user to login if not logged if (!userState.isLoggedIn) { await login() } if (typeof loadMinterBoardPage === "undefined") { console.log( "loadMinterBoardPage not found, loading script dynamically..." ) await loadScript("./assets/js/MinterBoard.js") } await loadAddRemoveAdminPage() }) }) // --- 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 || userState.isForumAdmin || userState.isMinterAdmin) { 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 = `
${ isAdmin ? '' : "" }
` document.body.appendChild(mainContent) // Add event listeners to room buttons document.getElementById("minters-room").addEventListener("click", () => { currentPage = 0 loadRoomContent("minters") }) if (userState.isAdmin) { document.getElementById("admins-room").addEventListener("click", () => { currentPage = 0 loadRoomContent("admins") }) } document.getElementById("general-room").addEventListener("click", () => { currentPage = 0 loadRoomContent("general") }) } // Function to add the pagination buttons and related control mechanisms ------------------------ const renderPaginationControls = (room, totalMessages, limit) => { const paginationContainer = document.getElementById("pagination-container") if (!paginationContainer) return paginationContainer.innerHTML = "" // Clear existing buttons const totalPages = Math.ceil(totalMessages / limit) // Add "Previous" button if (currentPage > 0) { const prevButton = document.createElement("button") prevButton.innerText = "Previous" prevButton.addEventListener("click", () => { if (currentPage > 0) { currentPage-- loadMessagesFromQDN(room, currentPage, false) } }) paginationContainer.appendChild(prevButton) } // Add numbered page buttons for (let i = 0; i < totalPages; i++) { const pageButton = document.createElement("button") pageButton.innerText = i + 1 pageButton.className = i === currentPage ? "active-page" : "" pageButton.addEventListener("click", () => { if (i !== currentPage) { currentPage = i loadMessagesFromQDN(room, currentPage, false) } }) paginationContainer.appendChild(pageButton) } // Add "Next" button if (currentPage < totalPages - 1) { const nextButton = document.createElement("button") nextButton.innerText = "Next" nextButton.addEventListener("click", () => { if (currentPage < totalPages - 1) { currentPage++ loadMessagesFromQDN(room, currentPage, false) } }) paginationContainer.appendChild(nextButton) } } // Main function to load the full content of the room, along with all main functionality ----------------------------------- const loadRoomContent = async (room) => { const forumContent = document.getElementById("forum-content") if (!forumContent) { console.error("Forum content container not found!") return } if (userState.isAdmin) { await loadOrFetchAdminGroupsData() } // Set initial content forumContent.innerHTML = `

${ room.charAt(0).toUpperCase() + room.slice(1) } Room

` // Add modal for image preview forumContent.insertAdjacentHTML( "beforeend", `
×
` ) initializeQuillEditor() setupModalHandlers() setupFileInputs(room) //TODO - maybe turn this into its own function and put it as a button? But for now it's fine to just load the latest message's position by default I think. const latestId = latestMessageIdentifiers[room]?.latestIdentifier if (latestId) { const page = await findMessagePage(room, latestId, 10) currentPage = page await loadMessagesFromQDN(room, currentPage) scrollToMessage(latestId.latestIdentifier) } else { await loadMessagesFromQDN(room, currentPage) } } // Initialize Quill editor //TODO check the updated editor init code // const initializeQuillEditor = () => { // new Quill('#editor', { // theme: 'snow', // modules: { // toolbar: [ // [{ 'font': [] }], // [{ 'size': ['small', false, 'large', 'huge'] }], // [{ 'header': [1, 2, false] }], // ['bold', 'italic', 'underline'], // [{ 'list': 'ordered'}, { 'list': 'bullet' }], // ['link', 'blockquote', 'code-block'], // [{ 'color': [] }, { 'background': [] }], // [{ 'align': [] }], // ['clean'] // ] // } // }); // }; const initializeQuillEditor = () => { const editorContainer = document.querySelector("#editor") if (!editorContainer) { console.error("Editor container not found!") return } new Quill("#editor", { theme: "snow", modules: { toolbar: [ [{ font: [] }], [{ indent: "-1" }, { indent: "+1" }], [{ header: [1, 2, 3, 5, false] }], ["bold", "italic", "underline", "strike"], ["blockquote", "code-block"], [{ list: "ordered" }, { list: "bullet" }], ["link", "blockquote", "code-block"], [{ color: [] }, { background: [] }], // ['link', 'image', 'video'], //todo attempt to add fancy base64 embed function for images, gif, and maybe small videos. [{ align: [] }], ["clean"], ], }, }) } // Set up modal behavior const setupModalHandlers = () => { document.addEventListener("click", (event) => { if (event.target.classList.contains("inline-image")) { const modal = document.getElementById("image-modal") const modalImage = document.getElementById("modal-image") const caption = document.getElementById("caption") modalImage.src = event.target.src caption.textContent = event.target.alt modal.style.display = "block" } }) document.getElementById("close-modal").addEventListener("click", () => { document.getElementById("image-modal").style.display = "none" }) window.addEventListener("click", (event) => { const modal = document.getElementById("image-modal") if (event.target === modal) { modal.style.display = "none" } }) } let selectedImages = [] let selectedFiles = [] let multiResource = [] let attachmentIdentifiers = [] // Set up file input handling const setupFileInputs = (room) => { const imageFileInput = document.getElementById("image-input") const previewContainer = document.getElementById("preview-container") const addToPublishButton = document.getElementById( "add-images-to-publish-button" ) const fileInput = document.getElementById("file-input") const sendButton = document.getElementById("send-button") const attachmentID = generateAttachmentID(room) imageFileInput.addEventListener("change", (event) => { previewContainer.innerHTML = "" selectedImages = [...event.target.files] addToPublishButton.disabled = selectedImages.length === 0 selectedImages.forEach((file, index) => { const reader = new FileReader() reader.onload = () => { const img = document.createElement("img") img.src = reader.result img.alt = file.name img.style = "width: 100px; height: 100px; object-fit: cover; border: 1px solid #ccc; border-radius: 5px;" const removeButton = document.createElement("button") removeButton.innerText = "Remove" removeButton.classList.add("remove-image-button") removeButton.onclick = () => { selectedImages.splice(index, 1) img.remove() removeButton.remove() addToPublishButton.disabled = selectedImages.length === 0 } const container = document.createElement("div") container.style = "display: flex; flex-direction: column; align-items: center; margin: 5px;" container.append(img, removeButton) previewContainer.append(container) } reader.readAsDataURL(file) }) }) addToPublishButton.addEventListener("click", () => { processSelectedImages(selectedImages, multiResource, room) selectedImages = [] imageFileInput.value = "" addToPublishButton.disabled = true }) fileInput.addEventListener("change", (event) => { selectedFiles = [...event.target.files] }) sendButton.addEventListener("click", async () => { const quill = new Quill("#editor") //TODO figure out what is going on with the quill initialization and so forth. const messageHtml = quill.root.innerHTML.trim() if (messageHtml || selectedFiles.length > 0 || selectedImages.length > 0) { await handleSendMessage( room, messageHtml, selectedFiles, selectedImages, multiResource ) } }) } // Process selected images const processSelectedImages = async (selectedImages, multiResource, room) => { for (const file of selectedImages) { const attachmentID = generateAttachmentID( room, selectedImages.indexOf(file) ) multiResource.push({ name: userState.accountName, service: room === "admins" ? "FILE_PRIVATE" : "FILE", identifier: attachmentID, file: file, // Use encrypted file for admins }) attachmentIdentifiers.push({ name: userState.accountName, service: room === "admins" ? "FILE_PRIVATE" : "FILE", identifier: attachmentID, filename: file.name, mimeType: file.type, }) } } // Handle send message const handleSendMessage = async ( room, messageHtml, selectedFiles, selectedImages, multiResource ) => { const messageIdentifier = room === "admins" ? `${messageIdentifierPrefix}-${room}-e-${randomID()}` : `${messageIdentifierPrefix}-${room}-${randomID()}` try { // Process selected images if (selectedImages.length > 0) { await processSelectedImages(selectedImages, multiResource, room) } // Process selected files if (selectedFiles && selectedFiles.length > 0) { for (const file of selectedFiles) { const attachmentID = generateAttachmentID( room, selectedFiles.indexOf(file) ) multiResource.push({ name: userState.accountName, service: room === "admins" ? "FILE_PRIVATE" : "FILE", identifier: attachmentID, file: file, // Use encrypted file for admins }) attachmentIdentifiers.push({ name: userState.accountName, service: room === "admins" ? "FILE_PRIVATE" : "FILE", identifier: attachmentID, filename: file.name, mimeType: file.type, }) } } // Build the message object const messageObject = { messageHtml, hasAttachment: multiResource.length > 0, attachments: attachmentIdentifiers, replyTo: replyToMessageIdentifier || null, // Include replyTo if applicable } // Encode the message object let base64Message = await objectToBase64(messageObject) if (!base64Message) { base64Message = btoa(JSON.stringify(messageObject)) } if (room === "admins" && userState.isAdmin) { console.log("Encrypting message for admins...") multiResource.push({ name: userState.accountName, service: "MAIL_PRIVATE", identifier: messageIdentifier, data64: base64Message, }) } else { multiResource.push({ name: userState.accountName, service: "BLOG_POST", identifier: messageIdentifier, data64: base64Message, }) } // Publish resources if (room === "admins") { if (!userState.isAdmin) { console.error( "User is not an admin or no admin public keys found. Aborting publish." ) window.alert("You are not authorized to post in the Admin room.") return } console.log("Publishing encrypted resources for Admin room...") await publishMultipleResources(multiResource, adminPublicKeys, true) } else { console.log("Publishing resources for non-admin room...") await publishMultipleResources(multiResource) } // Clear inputs and show success notification clearInputs() showSuccessNotification() } catch (error) { console.error("Error sending message:", error) } } function clearInputs() { // Clear the file input elements and preview container document.getElementById("file-input").value = "" document.getElementById("image-input").value = "" document.getElementById("preview-container").innerHTML = "" // Reset the Quill editor const quill = new Quill("#editor") quill.setContents([]) quill.setSelection(0) // Reset other state variables replyToMessageIdentifier = null multiResource = [] attachmentIdentifiers = [] selectedImages = [] selectedFiles = [] // Remove the reply container const replyContainer = document.querySelector(".reply-container") if (replyContainer) { replyContainer.remove() } } // Show success notification const showSuccessNotification = () => { const notification = document.createElement("div") notification.innerText = "Successfully Published! Please note that messages will not display until after they are CONFIRMED, be patient!" notification.style.color = "green" notification.style.marginTop = "1em" document.querySelector(".message-input-section").appendChild(notification) // alert(`Successfully Published! Please note that messages will not display until after they are CONFIRMED, be patient!`) setTimeout(() => { notification.remove() }, 10000) } // Generate unique attachment ID const generateAttachmentID = (room, fileIndex = null) => { const baseID = room === "admins" ? `${messageAttachmentIdentifierPrefix}-${room}-e-${randomID()}` : `${messageAttachmentIdentifierPrefix}-${room}-${randomID()}` return fileIndex !== null ? `${baseID}-${fileIndex}` : baseID } // --- REFACTORED LOAD MESSAGES AND HELPER FUNCTIONS --- const findMessagePage = async (room, identifier, limit) => { const { service, query } = getServiceAndQuery(room) //TODO check that searchSimple change worked. const allMessages = await searchSimple( service, query, "", 0, 0, room, "false" ) const idx = allMessages.findIndex((msg) => msg.identifier === identifier) if (idx === -1) { // Not found, default to last page or page=0 return 0 } return Math.floor(idx / limit) } const loadMessagesFromQDN = async (room, page, isPolling = false) => { try { const limit = 10 const offset = page * limit console.log( `Loading messages from QDN: room=${room}, page=${page}, offset=${offset}, limit=${limit}` ) const messagesContainer = document.querySelector("#messages-container") if (!messagesContainer) return prepareMessageContainer(messagesContainer, isPolling) const { service, query } = getServiceAndQuery(room) const response = await fetchResourceList( service, query, limit, offset, room ) console.log(`Fetched ${response.length} message(s) for page ${page}.`) if ( handleNoMessagesScenario(isPolling, page, response, messagesContainer) ) { return } // Re-establish existing identifiers after preparing container existingIdentifiers = new Set( Array.from(messagesContainer.querySelectorAll(".message-item")).map( (item) => item.dataset.identifier ) ) let mostRecentMessage = getCurrentMostRecentMessage(room) const fetchMessages = await fetchAllMessages(response, service, room) for (const msg of fetchMessages) { if (!msg) continue storeMessageInMap(msg) } const { firstNewMessageIdentifier, updatedMostRecentMessage } = await renderNewMessages( fetchMessages, existingIdentifiers, messagesContainer, room, mostRecentMessage ) if (firstNewMessageIdentifier && !isPolling) { scrollToNewMessages(firstNewMessageIdentifier) } if (updatedMostRecentMessage) { updateLatestMessageIdentifiers(room, updatedMostRecentMessage) } handleReplyLogic(fetchMessages) await updatePaginationControls(room, limit) } catch (error) { console.error("Error loading messages from QDN:", error) } } function scrollToMessage(identifier) { const targetElement = document.querySelector( `.message-item[data-identifier="${identifier}"]` ) if (targetElement) { targetElement.scrollIntoView({ behavior: "smooth", block: "center" }) } } /** Helper Functions (Arrow Functions) **/ const prepareMessageContainer = (messagesContainer, isPolling) => { if (!isPolling) { messagesContainer.innerHTML = "" existingIdentifiers.clear() } } const getServiceAndQuery = (room) => { const service = room === "admins" ? "MAIL_PRIVATE" : "BLOG_POST" const query = room === "admins" ? `${messageIdentifierPrefix}-${room}-e` : `${messageIdentifierPrefix}-${room}` return { service, query } } const fetchResourceList = async (service, query, limit, offset, room) => { //TODO check return await searchSimple(service, query, "", limit, offset, room, "false") } const handleNoMessagesScenario = ( isPolling, page, response, messagesContainer ) => { if (response.length === 0) { if (page === 0 && !isPolling) { messagesContainer.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/${encodeURIComponent( message.name )}/qortal_avatar` const safeName = qEscapeHtml(message.name) const safeDate = qEscapeHtml(message.date) // Kakashi Note: Forum messages are sanitized before render so rich text remains readable without allowing injected scripts. const safeMessageContent = qSanitizeRichHtml(message.content) return `
Avatar ${safeName} ${ isNewMessage ? `NEW` : "" }
${safeDate}
${replyHtml}
${safeMessageContent}
` } 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) const safeReplyName = qEscapeHtml(savedRepliedToMessage.name) const safeReplyDate = qEscapeHtml(savedRepliedToMessage.date) const safeReplyContent = qSanitizeRichHtml(savedRepliedToMessage.content) return `
In reply to: ${safeReplyName} ${safeReplyDate}
${safeReplyContent}
` } 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 const safeReplyName = qEscapeHtml(repliedMessage.name) const safeReplyDate = qEscapeHtml(repliedMessage.date) const safeReplyContent = qSanitizeRichHtml(repliedMessage.content) return `
In reply to: ${safeReplyName} ${safeReplyDate}
${safeReplyContent}
` } 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) => { // Kakashi Note: Attachment metadata is escaped and passed through data-* attributes for safe button handlers. const safeService = qEscapeAttr(attachment.service) const safeName = qEscapeAttr(attachment.name) const safeIdentifier = qEscapeAttr(attachment.identifier) const safeFilenameAttr = qEscapeAttr(attachment.filename) const safeFilenameText = qEscapeHtml(attachment.filename) const safeMimeType = qEscapeAttr(attachment.mimeType) if ( room !== "admins" && attachment.mimeType && attachment.mimeType.startsWith("image/") ) { const imageUrl = `/arbitrary/${encodeURIComponent( attachment.service )}/${encodeURIComponent(attachment.name)}/${encodeURIComponent( attachment.identifier )}` return `
${safeFilenameAttr}
` } 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}` //${attachment.filename} // above copied from removed html that is now created with fetchImageUrl TODO test this to ensure it works as expected. const imageHtml = await loadInLineImageHtml( attachment.service, attachment.name, attachment.identifier, attachment.filename, attachment.mimeType, "admins" ) return `
${imageHtml}
` } else { return `
` } } const fetchAndSaveAttachmentFromButton = (buttonEl) => { if (!buttonEl) return const service = buttonEl.dataset?.service || "" const name = buttonEl.dataset?.name || "" const identifier = buttonEl.dataset?.identifier || "" const filename = buttonEl.dataset?.filename || "" const mimeType = buttonEl.dataset?.mimetype || "" fetchAndSaveAttachment(service, name, identifier, filename, mimeType) } 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 safeReplyPreview = qSanitizeRichHtml(repliedMessage.content) const replyContainer = document.createElement("div") replyContainer.className = "reply-container" replyContainer.innerHTML = `
Replying to: ${safeReplyPreview}
` 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) } const createScrollToTopButton = () => { if (document.getElementById("scrollToTopButton")) return const button = document.createElement("button") button.id = "scrollToTopButton" button.innerHTML = "↑" // Initial “not visible” state button.style.display = "none" button.style.position = "fixed" button.style.bottom = "3vh" button.style.right = "3vw" button.style.width = "9vw" button.style.height = "9vw" button.style.minWidth = "45px" button.style.minHeight = "45px" button.style.maxWidth = "60px" button.style.maxHeight = "60px" button.style.borderRadius = "50%" button.style.backgroundColor = "black" button.style.color = "white" button.style.border = "2px solid white" button.style.boxShadow = "0 0 15px rgba(0,0,0,0.5)" button.style.cursor = "pointer" button.style.zIndex = "1000" button.style.transition = "opacity 0.3s ease, transform 0.3s ease" button.style.fontSize = "5vw" button.style.minFontSize = "18px" button.style.maxFontSize = "30px" button.onclick = () => { window.scrollTo({ top: 0, behavior: "smooth" }) } document.body.appendChild(button) const adjustFontSize = () => { const computedStyle = window.getComputedStyle(button) let sizePx = parseFloat(computedStyle.fontSize) if (sizePx < 18) sizePx = 18 if (sizePx > 30) sizePx = 30 button.style.fontSize = sizePx + "px" } adjustFontSize() window.addEventListener("resize", adjustFontSize) window.addEventListener("scroll", () => { if (window.scrollY > 200) { button.style.display = "block" } else { button.style.display = "none" } }) } // 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) }