`
// 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 `
${safeName}
${
isNewMessage
? `NEW`
: ""
}
${safeDate}
${replyHtml}
${safeMessageContent}
${attachmentHtml}
`
}
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 `
`
} 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}`
//
// 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 `