Massive changes, all bugs have been fixed, Admin Board now allows 'topic mode' and 'name mode' to allow cards to be published with both. Admin Room in the forum now works with encrypted attachments and images, along with image preview and download button. One small issue remains with download button on image pop-up modal. But that will be fixed in patch release.

This commit is contained in:
crowetic 2024-12-24 00:27:17 -08:00
parent 5b30fff84f
commit 0fc471a1b8
8 changed files with 400 additions and 186 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,11 +1,14 @@
// NOTE - Change isTestMode to false prior to actual release ---- !important - You may also change identifier if you want to not show older cards. // NOTE - Change isTestMode to false prior to actual release ---- !important - You may also change identifier if you want to not show older cards.
const isEncryptedTestMode = true const isEncryptedTestMode = true
const encryptedCardIdentifierPrefix = "test-MDC" const encryptedCardIdentifierPrefix = "card-MAC"
let isExistingEncryptedCard = false let isExistingEncryptedCard = false
let existingDecryptedCardData = {} let existingDecryptedCardData = {}
let existingEncryptedCardIdentifier = {} let existingEncryptedCardIdentifier = {}
let cardMinterName = {} let cardMinterName = {}
let existingCardMinterNames = [] let existingCardMinterNames = []
let isTopic = false
let attemptLoadAdminDataCount = 0
let adminMemberCount = 0
console.log("Attempting to load AdminBoard.js"); console.log("Attempting to load AdminBoard.js");
@ -32,12 +35,16 @@ const loadAdminBoardPage = async () => {
<div id="publish-card-view" class="publish-card-view" style="display: none; text-align: left; padding: 20px;"> <div id="publish-card-view" class="publish-card-view" style="display: none; text-align: left; padding: 20px;">
<form id="publish-card-form"> <form id="publish-card-form">
<h3>Create or Update Your Minter Card</h3> <h3>Create or Update Your Minter Card</h3>
<label for="minter-name-input">Minter Name:</label> <div class="publish-card-checkbox" style="margin-top: 1em;">
<input type="text" id="minter-name-input" maxlength="100" placeholder="Enter Minter's Name" required> <input type="checkbox" id="topic-checkbox" name="topicMode" />
<label for="topic-checkbox">Is this a Topic instead of a Minter?</label>
</div>
<label for="minter-name-input">Input Topic or Minter Name:</label>
<input type="text" id="minter-name-input" maxlength="100" placeholder="Enter Topic or Minter's Name" required>
<label for="card-header">Header:</label> <label for="card-header">Header:</label>
<input type="text" id="card-header" maxlength="100" placeholder="Explain main point/issue" required> <input type="text" id="card-header" maxlength="100" placeholder="Explain main point/issue" required>
<label for="card-content">Content:</label> <label for="card-content">Content:</label>
<textarea id="card-content" placeholder="Enter any information you like...You may also attach links to more in-depth information, etc." required></textarea> <textarea id="card-content" placeholder="Enter any information you like...You may also attach links to more in-depth information, etc. (Links will pop-up in a custom in-app viewer upon being clicked.)" required></textarea>
<label for="card-links">Links (qortal://...):</label> <label for="card-links">Links (qortal://...):</label>
<div id="links-container"> <div id="links-container">
<input type="text" class="card-link" placeholder="Enter QDN link"> <input type="text" class="card-link" placeholder="Enter QDN link">
@ -90,11 +97,63 @@ const loadAdminBoardPage = async () => {
document.getElementById("publish-card-form").addEventListener("submit", async (event) => { document.getElementById("publish-card-form").addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
await publishEncryptedCard(); const isTopicChecked = document.getElementById("topic-checkbox").checked;
// Pass that boolean to publishEncryptedCard
await publishEncryptedCard(isTopicChecked);
}); });
// await fetchAndValidateAllAdminCards(); // await fetchAndValidateAllAdminCards();
await fetchAllEncryptedCards(); await fetchAllEncryptedCards();
await updateOrSaveAdminGroupsDataLocally();
}
// Example: fetch and save admin public keys and count
const updateOrSaveAdminGroupsDataLocally = async () => {
try {
// Fetch the array of admin public keys
const verifiedAdminPublicKeys = await fetchAdminGroupsMembersPublicKeys();
// Build an object containing the count and the array
const adminData = {
keysCount: verifiedAdminPublicKeys.length,
publicKeys: verifiedAdminPublicKeys
};
// Stringify and save to localStorage
localStorage.setItem('savedAdminData', JSON.stringify(adminData));
console.log('Admin public keys saved locally:', adminData);
} catch (error) {
console.error('Error fetching/storing admin public keys:', error);
attemptLoadAdminDataCount++
}
};
const loadOrFetchAdminGroupsData = async () => {
try {
// Pull the JSON from localStorage
const storedData = localStorage.getItem('savedAdminData')
if (!storedData && attemptLoadAdminDataCount <= 3) {
console.log('No saved admin public keys found in local storage. Fetching...')
await updateOrSaveAdminGroupsDataLocally()
attemptLoadAdminDataCount++
return null;
}
// Parse the JSON, then store the global variables.
const parsedData = JSON.parse(storedData)
adminMemberCount = parsedData.keysCount
adminPublicKeys = parsedData.publicKeys
console.log(`Loaded admins 'keysCount'=${adminMemberCount}, adminKeys=`, adminPublicKeys)
attemptLoadAdminDataCount = 0
return parsedData; // and return { adminMemberCount, adminKeys } to the caller
} catch (error) {
console.error('Error loading/parsing saved admin public keys:', error)
return null
}
} }
const extractCardsMinterName = (cardIdentifier) => { const extractCardsMinterName = (cardIdentifier) => {
@ -108,6 +167,10 @@ const extractCardsMinterName = (cardIdentifier) => {
if (parts.length < 3) { if (parts.length < 3) {
throw new Error('Invalid identifier format'); throw new Error('Invalid identifier format');
} }
if (parts.slice(2, -1).join('-') === 'TOPIC') {
console.log(`TOPIC found in identifier: ${cardIdentifier} - not including in duplicatesList`)
return
}
// Extract minterName (everything from the second part to the second-to-last part) // Extract minterName (everything from the second part to the second-to-last part)
const minterName = parts.slice(2, -1).join('-'); const minterName = parts.slice(2, -1).join('-');
// Return the extracted minterName // Return the extracted minterName
@ -241,9 +304,10 @@ const fetchAllEncryptedCards = async () => {
// Fetch poll results // Fetch poll results
const pollResults = await fetchPollResults(decryptedCardData.poll); const pollResults = await fetchPollResults(decryptedCardData.poll);
const minterNameFromIdentifier = await extractCardsMinterName(card.identifier); // const minterNameFromIdentifier = await extractCardsMinterName(card.identifier);
const encryptedCommentCount = await getEncryptedCommentCount(card.identifier); const encryptedCommentCount = await getEncryptedCommentCount(card.identifier);
// Generate final card HTML // Generate final card HTML
const finalCardHTML = await createEncryptedCardHTML(decryptedCardData, pollResults, card.identifier, encryptedCommentCount); const finalCardHTML = await createEncryptedCardHTML(decryptedCardData, pollResults, card.identifier, encryptedCommentCount);
replaceEncryptedSkeleton(card.identifier, finalCardHTML); replaceEncryptedSkeleton(card.identifier, finalCardHTML);
} catch (error) { } catch (error) {
@ -386,9 +450,6 @@ const loadEncryptedCardIntoForm = async () => {
const validateMinterName = async(minterName) => { const validateMinterName = async(minterName) => {
try { try {
const nameInfo = await getNameInfo(minterName) const nameInfo = await getNameInfo(minterName)
if (!nameInfo) {
return error (`No NameInfo able to be obtained? Did you pass name?`)
}
const name = nameInfo.name const name = nameInfo.name
return name return name
} catch (error){ } catch (error){
@ -396,66 +457,77 @@ const validateMinterName = async(minterName) => {
} }
} }
// Main function to publish a new Minter Card ----------------------------------------------- const publishEncryptedCard = async (isTopicModePassed = false) => {
const publishEncryptedCard = async () => { // If the user wants it to be a topic, we set global isTopic = true, else false
isTopic = isTopicModePassed;
const minterNameInput = document.getElementById("minter-name-input").value.trim(); const minterNameInput = document.getElementById("minter-name-input").value.trim();
const header = document.getElementById("card-header").value.trim(); const header = document.getElementById("card-header").value.trim();
const content = document.getElementById("card-content").value.trim(); const content = document.getElementById("card-content").value.trim();
const links = Array.from(document.querySelectorAll(".card-link")) const links = Array.from(document.querySelectorAll(".card-link"))
.map(input => input.value.trim()) .map(input => input.value.trim())
.filter(link => link.startsWith("qortal://")); .filter(link => link.startsWith("qortal://"));
const publishedMinterName = await validateMinterName(minterNameInput)
// Basic validation
if (!header || !content) { if (!header || !content) {
alert("Header and Content are required!"); alert("Header and Content are required!");
return; return;
} }
if (!publishedMinterName) { let publishedMinterName = minterNameInput;
alert(`Minter name invalid! Name input: ${minterNameInput} - please check the name and try again!`)
return;
}
if (!isExistingEncryptedCard) { // If not topic mode, validate the user actually entered a valid Minter name
if (existingCardMinterNames.includes(publishedMinterName)) { if (!isTopic) {
const updateCard = confirm(`Minter Name: ${publishedMinterName} - CARD ALREADY EXISTS, you can update it (overwriting existing publish) or cancel... `) publishedMinterName = await validateMinterName(minterNameInput);
if (updateCard) { if (!publishedMinterName) {
await fetchExistingEncryptedCard(publishedMinterName) alert(`"${minterNameInput}" doesn't seem to be a valid Minter name. Please check or use topic mode.`);
await loadEncryptedCardIntoForm()
isExistingEncryptedCard = true
return
}else {
return; return;
}
// Also check for existing card if not topic
if (!isExistingEncryptedCard && existingCardMinterNames.includes(publishedMinterName)) {
const updateCard = confirm(
`Minter Name: ${publishedMinterName} already has a card. Update or Cancel?`
);
if (updateCard) {
await fetchExistingEncryptedCard(publishedMinterName);
await loadEncryptedCardIntoForm();
isExistingEncryptedCard = true;
return;
} else {
return;
} }
} }
} }
const cardIdentifier = isExistingEncryptedCard ? existingEncryptedCardIdentifier : `${encryptedCardIdentifierPrefix}-${publishedMinterName}-${await uid()}`; // Determine final card identifier
const pollName = `${cardIdentifier}-poll`; const newCardIdentifier = isTopic
const pollDescription = `Admin Board Poll Published By ${userState.accountName}`; ? `${encryptedCardIdentifierPrefix}-TOPIC-${await uid()}`
: `${encryptedCardIdentifierPrefix}-${publishedMinterName}-${await uid()}`;
const cardIdentifier = isExistingEncryptedCard ? existingEncryptedCardIdentifier : newCardIdentifier;
// Build cardData
const pollName = `${cardIdentifier}-poll`;
const cardData = { const cardData = {
minterName: `${publishedMinterName}`, minterName: publishedMinterName,
header, header,
content, content,
links, links,
creator: userState.accountName, creator: userState.accountName,
timestamp: Date.now(), timestamp: Date.now(),
poll: pollName, poll: pollName,
topicMode: isTopic
}; };
try {
try {
// Convert to base64 or fallback
let base64CardData = await objectToBase64(cardData); let base64CardData = await objectToBase64(cardData);
if (!base64CardData) { if (!base64CardData) {
console.log(`initial base64 object creation with objectToBase64 failed, using btoa...`);
base64CardData = btoa(JSON.stringify(cardData)); base64CardData = btoa(JSON.stringify(cardData));
} }
const verifiedAdminPublicKeys = (adminPublicKeys) ? adminPublicKeys : loadOrFetchAdminGroupsData().publicKeys
const verifiedAdminPublicKeys = await fetchAdminGroupsMembersPublicKeys()
adminPublicKeys = verifiedAdminPublicKeys
await qortalRequest({ await qortalRequest({
action: "PUBLISH_QDN_RESOURCE", action: "PUBLISH_QDN_RESOURCE",
name: userState.accountName, name: userState.accountName,
@ -466,30 +538,36 @@ const publishEncryptedCard = async () => {
publicKeys: verifiedAdminPublicKeys publicKeys: verifiedAdminPublicKeys
}); });
if (!isExistingEncryptedCard){ // Possibly create a poll if its a brand new card
await qortalRequest({ if (!isExistingEncryptedCard) {
action: "CREATE_POLL", await qortalRequest({
pollName, action: "CREATE_POLL",
pollDescription, pollName,
pollOptions: ['Yes, No'], pollDescription: `Admin Board Poll Published By ${userState.accountName}`,
pollOwnerAddress: userState.accountAddress, pollOptions: ["Yes, No"],
}); pollOwnerAddress: userState.accountAddress
});
alert("Card and poll published successfully!"); alert("Card and poll published successfully!");
existingCardMinterNames.push(`${publishedMinterName}`)
// If its a real Minter name, store it so we know we have a card for them
if (!isTopic) {
existingCardMinterNames.push(publishedMinterName);
}
} else {
alert("Card updated successfully! (No poll updates possible currently...)");
} }
if (isExistingEncryptedCard){ // Cleanup UI
alert("Card Updated Successfully! (No poll updates are possible at this time...)")
}
document.getElementById("publish-card-form").reset(); document.getElementById("publish-card-form").reset();
document.getElementById("publish-card-view").style.display = "none"; document.getElementById("publish-card-view").style.display = "none";
document.getElementById("encrypted-cards-container").style.display = "flex"; document.getElementById("encrypted-cards-container").style.display = "flex";
isTopic = false; // reset global
} catch (error) { } catch (error) {
console.error("Error publishing card or poll:", error); console.error("Error publishing card or poll:", error);
alert("Failed to publish card and poll."); alert("Failed to publish card and poll.");
} }
} };
const getEncryptedCommentCount = async (cardIdentifier) => { const getEncryptedCommentCount = async (cardIdentifier) => {
try { try {
@ -528,10 +606,9 @@ const postEncryptedComment = async (cardIdentifier) => {
const commentIdentifier = `comment-${cardIdentifier}-${await uid()}`; const commentIdentifier = `comment-${cardIdentifier}-${await uid()}`;
if (!Array.isArray(adminPublicKeys) || (adminPublicKeys.length === 0)) { if (!Array.isArray(adminPublicKeys) || (adminPublicKeys.length === 0)) {
const verifiedAdminPublicKeys = await fetchAdminGroupsMembersPublicKeys() const verifiedAdminPublicKeys = await loadOrFetchAdminGroupsData().publicKeys
adminPublicKeys = verifiedAdminPublicKeys adminPublicKeys = verifiedAdminPublicKeys
} }
try { try {
const base64CommentData = await objectToBase64(commentData); const base64CommentData = await objectToBase64(commentData);
@ -744,30 +821,54 @@ async function getMinterAvatar(minterName) {
// Create the overall Minter Card HTML ----------------------------------------------- // Create the overall Minter Card HTML -----------------------------------------------
const createEncryptedCardHTML = async (cardData, pollResults, cardIdentifier, commentCount) => { const createEncryptedCardHTML = async (cardData, pollResults, cardIdentifier, commentCount) => {
const { minterName, header, content, links, creator, timestamp, poll } = cardData; const { minterName, header, content, links, creator, timestamp, poll, topicMode } = cardData
const formattedDate = new Date(timestamp).toLocaleString(); const formattedDate = new Date(timestamp).toLocaleString()
const minterAvatar = await getMinterAvatar(minterName) const minterAvatar = !topicMode ? await getMinterAvatar(minterName) : null
// const creatorAvatar = `/arbitrary/THUMBNAIL/${creator}/qortal_avatar`; // const creatorAvatar = `/arbitrary/THUMBNAIL/${creator}/qortal_avatar`;
const creatorAvatar = await getMinterAvatar(creator) const creatorAvatar = await getMinterAvatar(creator)
const linksHTML = links.map((link, index) => ` const linksHTML = links.map((link, index) => `
<button onclick="openLinkDisplayModal('${link}')"> <button onclick="openLinkDisplayModal('${link}')">
${`Link ${index + 1} - ${link}`} ${`Link ${index + 1} - ${link}`}
</button> </button>
`).join(""); `).join("")
const minterGroupMembers = await fetchMinterGroupMembers(); const isUndefinedUser = (minterName === 'undefined')
const minterAdmins = await fetchMinterGroupAdmins();
const hasTopicMode = Object.prototype.hasOwnProperty.call(cardData, 'topicMode')
// 2) Decide if this card is showing as "Topic" or "Name"
let showTopic = false
if (hasTopicMode) {
// If present, see if it's actually "true" or true
const modeVal = cardData.topicMode;
showTopic = (modeVal === true || modeVal === 'true')
} else {
if (!isUndefinedUser) {
// No topicMode => older card => default to Name
showTopic = false
}
}
const cardColorCode = showTopic ? '#0e1b15' : '#151f28'
const minterOrTopicHtml = ((showTopic) || (isUndefinedUser)) ? `
<div class="support-header"><h5> REGARDING (Topic): </h5></div>
<h3>${minterName}</h3>` :
`
<div class="support-header"><h5> REGARDING (Name): </h5></div>
${minterAvatar}
<h3>${minterName}</h3>`
const minterGroupMembers = await fetchMinterGroupMembers()
const minterAdmins = await fetchMinterGroupAdmins()
const { adminYes = 0, adminNo = 0, minterYes = 0, minterNo = 0, totalYes = 0, totalNo = 0, totalYesWeight = 0, totalNoWeight = 0 } = await calculateAdminBoardPollResults(pollResults, minterGroupMembers, minterAdmins) const { adminYes = 0, adminNo = 0, minterYes = 0, minterNo = 0, totalYes = 0, totalNo = 0, totalYesWeight = 0, totalNoWeight = 0 } = await calculateAdminBoardPollResults(pollResults, minterGroupMembers, minterAdmins)
await createModal() await createModal()
return ` return `
<div class="admin-card"> <div class="admin-card" style="background-color: ${cardColorCode}">
<div class="minter-card-header"> <div class="minter-card-header">
<h2 class="support-header"> Created By: </h2> <h2 class="support-header"> Created By: </h2>
${creatorAvatar} ${creatorAvatar}
<h2>${creator}</h2> <h2>${creator}</h2>
<div class="support-header"><h5> REGARDING: </h5></div> ${minterOrTopicHtml}
${minterAvatar}
<h3>${minterName}</h3>
<p>${header}</p> <p>${header}</p>
</div> </div>
<div class="info"> <div class="info">
@ -788,14 +889,14 @@ const createEncryptedCardHTML = async (cardData, pollResults, cardIdentifier, co
<span class="minter-no">Denial Weight ${totalNoWeight}</span> <span class="minter-no">Denial Weight ${totalNoWeight}</span>
</div> </div>
</div> </div>
<div class="support-header"><h5>SUPPORT or DENY</h5><h5 style="color: #ffae42;">${minterName}</h5> <div class="support-header"><h5>ACTIONS FOR</h5><h5 style="color: #ffae42;">${minterName}</h5>
<p style="color: #c7c7c7; font-size: .65rem; margin-top: 1vh">(click COMMENTS button to open/close card comments)</p> <p style="color: #c7c7c7; font-size: .65rem; margin-top: 1vh">(click COMMENTS button to open/close card comments)</p>
</div> </div>
<div class="actions"> <div class="actions">
<div class="actions-buttons"> <div class="actions-buttons">
<button class="yes" onclick="voteYesOnPoll('${poll}')">SUPPORT</button> <button class="yes" onclick="voteYesOnPoll('${poll}')">YES</button>
<button class="comment" onclick="toggleEncryptedComments('${cardIdentifier}')">COMMENTS (${commentCount})</button> <button class="comment" onclick="toggleEncryptedComments('${cardIdentifier}')">COMMENTS (${commentCount})</button>
<button class="no" onclick="voteNoOnPoll('${poll}')">OPPOSE</button> <button class="no" onclick="voteNoOnPoll('${poll}')">NO</button>
</div> </div>
</div> </div>
<div id="comments-section-${cardIdentifier}" class="comments-section" style="display: none; margin-top: 20px;"> <div id="comments-section-${cardIdentifier}" class="comments-section" style="display: none; margin-top: 20px;">
@ -805,6 +906,6 @@ const createEncryptedCardHTML = async (cardData, pollResults, cardIdentifier, co
</div> </div>
<p style="font-size: 0.75rem; margin-top: 1vh; color: #4496a1">By: ${creator} - ${formattedDate}</p> <p style="font-size: 0.75rem; margin-top: 1vh; color: #4496a1">By: ${creator} - ${formattedDate}</p>
</div> </div>
`; `
} }

View File

@ -1,6 +1,6 @@
// // NOTE - Change isTestMode to false prior to actual release ---- !important - You may also change identifier if you want to not show older cards. // // NOTE - Change isTestMode to false prior to actual release ---- !important - You may also change identifier if you want to not show older cards.
const testMode = true; const testMode = false;
const cardIdentifierPrefix = "testMB-board-card"; const cardIdentifierPrefix = "Minter-board-card";
let isExistingCard = false; let isExistingCard = false;
let existingCardData = {}; let existingCardData = {};
let existingCardIdentifier = {}; let existingCardIdentifier = {};
@ -17,8 +17,8 @@ const loadMinterBoardPage = async () => {
// Add the "Minter Board" content // Add the "Minter Board" content
const mainContent = document.createElement("div"); const mainContent = document.createElement("div");
const publishButtonColor = generateDarkPastelBackgroundBy("MinterBoardPublishButton") const publishButtonColor = '#527c9d'
const minterBoardNameColor = generateDarkPastelBackgroundBy(randomID) const minterBoardNameColor = '#527c9d'
mainContent.innerHTML = ` mainContent.innerHTML = `
<div class="minter-board-main" style="padding: 20px; text-align: center;"> <div class="minter-board-main" style="padding: 20px; text-align: center;">
<h1 style="color: ${minterBoardNameColor};">Minter Board</h1> <h1 style="color: ${minterBoardNameColor};">Minter Board</h1>
@ -192,7 +192,8 @@ const loadCards = async () => {
const BgColor = generateDarkPastelBackgroundBy(card.name) const BgColor = generateDarkPastelBackgroundBy(card.name)
// Generate final card HTML // Generate final card HTML
const commentCount = await countComments(card.identifier) const commentCount = await countComments(card.identifier)
const finalCardHTML = await createCardHTML(cardDataResponse, pollResults, card.identifier, commentCount, BgColor); const cardUpdatedTime = card.updated || null
const finalCardHTML = await createCardHTML(cardDataResponse, pollResults, card.identifier, commentCount, cardUpdatedTime, BgColor);
replaceSkeleton(card.identifier, finalCardHTML); replaceSkeleton(card.identifier, finalCardHTML);
} catch (error) { } catch (error) {
@ -636,9 +637,9 @@ const generateDarkPastelBackgroundBy = (name) => {
// Create the overall Minter Card HTML ----------------------------------------------- // Create the overall Minter Card HTML -----------------------------------------------
const createCardHTML = async (cardData, pollResults, cardIdentifier, commentCount, BgColor) => { const createCardHTML = async (cardData, pollResults, cardIdentifier, commentCount, cardUpdatedTime, BgColor) => {
const { header, content, links, creator, timestamp, poll } = cardData; const { header, content, links, creator, timestamp, poll } = cardData;
const formattedDate = new Date(timestamp).toLocaleString(); const formattedDate = cardUpdatedTime ? new Date(cardUpdatedTime).toLocaleString() : new Date(timestamp).toLocaleString()
// const avatarUrl = `/arbitrary/THUMBNAIL/${creator}/qortal_avatar`; // const avatarUrl = `/arbitrary/THUMBNAIL/${creator}/qortal_avatar`;
const avatarHtml = await getMinterAvatar(creator) const avatarHtml = await getMinterAvatar(creator)
const linksHTML = links.map((link, index) => ` const linksHTML = links.map((link, index) => `

View File

@ -132,7 +132,7 @@ const loadForumPage = async () => {
} }
} }
if (typeof userState.isAdmin === 'undefined') { if ((typeof userState.isAdmin === 'undefined') || (!userState.isAdmin)){
try { try {
// Fetch and verify the admin status asynchronously // Fetch and verify the admin status asynchronously
userState.isAdmin = await verifyUserIsAdmin(); userState.isAdmin = await verifyUserIsAdmin();
@ -244,6 +244,10 @@ const loadRoomContent = async (room) => {
return; return;
} }
if (userState.isAdmin) {
await loadOrFetchAdminGroupsData()
}
// Set initial content // Set initial content
forumContent.innerHTML = ` forumContent.innerHTML = `
<div class="room-content"> <div class="room-content">
@ -258,7 +262,7 @@ const loadRoomContent = async (room) => {
<label for="file-input" class="custom-file-input-button">Select Files</label> <label for="file-input" class="custom-file-input-button">Select Files</label>
<input type="file" id="image-input" class="image-input" multiple accept="image/*"> <input type="file" id="image-input" class="image-input" multiple accept="image/*">
<label for="image-input" class="custom-image-input-button">Select IMAGES w/Preview</label> <label for="image-input" class="custom-image-input-button">Select IMAGES w/Preview</label>
<button id="add-images-to-publish-button" disabled>Add Images to Multi-Publish</button> <button id="add-images-to-publish-button" style="display: none" disabled>Add Images to Multi-Publish</button>
<div id="preview-container" style="display: flex; flex-wrap: wrap; gap: 10px;"></div> <div id="preview-container" style="display: flex; flex-wrap: wrap; gap: 10px;"></div>
</div> </div>
<button id="send-button" class="send-button">Publish</button> <button id="send-button" class="send-button">Publish</button>
@ -381,6 +385,7 @@ const setupFileInputs = (room) => {
addToPublishButton.addEventListener('click', () => { addToPublishButton.addEventListener('click', () => {
processSelectedImages(selectedImages, multiResource, room); processSelectedImages(selectedImages, multiResource, room);
selectedImages = []; selectedImages = [];
imageFileInput.value = "";
addToPublishButton.disabled = true; addToPublishButton.disabled = true;
}); });
@ -424,12 +429,12 @@ const processSelectedImages = async (selectedImages, multiResource, room) => {
// Handle send message // Handle send message
const handleSendMessage = async (room, messageHtml, selectedFiles, selectedImages, multiResource) => { const handleSendMessage = async (room, messageHtml, selectedFiles, selectedImages, multiResource) => {
const messageIdentifier = room === "admins" const messageIdentifier = room === "admins"
? `${messageIdentifierPrefix}-${room}-e-${Date.now()}` ? `${messageIdentifierPrefix}-${room}-e-${randomID()}`
: `${messageIdentifierPrefix}-${room}-${Date.now()}`; : `${messageIdentifierPrefix}-${room}-${randomID()}`;
const adminPublicKeys = room === "admins" && userState.isAdmin // const checkedAdminPublicKeys = room === "admins" && userState.isAdmin
? await fetchAdminGroupsMembersPublicKeys() // ? adminPublicKeys
: []; // : await loadOrFetchAdminGroupsData().publicKeys;
try { try {
// Process selected images // Process selected images
@ -481,19 +486,19 @@ const handleSendMessage = async (room, messageHtml, selectedFiles, selectedImage
service: "MAIL_PRIVATE", service: "MAIL_PRIVATE",
identifier: messageIdentifier, identifier: messageIdentifier,
data64: base64Message, data64: base64Message,
}); })
} else { } else {
multiResource.push({ multiResource.push({
name: userState.accountName, name: userState.accountName,
service: "BLOG_POST", service: "BLOG_POST",
identifier: messageIdentifier, identifier: messageIdentifier,
data64: base64Message, data64: base64Message,
}); })
} }
// Publish resources // Publish resources
if (room === "admins") { if (room === "admins") {
if (!userState.isAdmin || adminPublicKeys.length === 0) { if (!userState.isAdmin) {
console.error("User is not an admin or no admin public keys found. Aborting publish."); 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."); window.alert("You are not authorized to post in the Admin room.");
return; return;
@ -596,7 +601,7 @@ const loadMessagesFromQDN = async (room, page, isPolling = false) => {
let mostRecentMessage = getCurrentMostRecentMessage(room); let mostRecentMessage = getCurrentMostRecentMessage(room);
const fetchMessages = await fetchAllMessages(response, service, room); const fetchMessages = await fetchAllMessages(response, service, room);
const { firstNewMessageIdentifier, updatedMostRecentMessage } = renderNewMessages( const { firstNewMessageIdentifier, updatedMostRecentMessage } = await renderNewMessages(
fetchMessages, fetchMessages,
existingIdentifiers, existingIdentifiers,
messagesContainer, messagesContainer,
@ -655,10 +660,29 @@ const getCurrentMostRecentMessage = (room) => {
return latestMessageIdentifiers[room]?.latestTimestamp ? latestMessageIdentifiers[room] : null; return latestMessageIdentifiers[room]?.latestTimestamp ? latestMessageIdentifiers[room] : null;
}; };
// 1) Convert fetchAllMessages to fully async
const fetchAllMessages = async (response, service, room) => { const fetchAllMessages = async (response, service, room) => {
return Promise.all(response.map(resource => fetchFullMessage(resource, 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) => { const fetchFullMessage = async (resource, service, room) => {
try { try {
// Skip if already displayed // Skip if already displayed
@ -702,6 +726,7 @@ const fetchFullMessage = async (resource, service, room) => {
} }
}; };
const processMessageObject = async (messageResponse, room) => { const processMessageObject = async (messageResponse, room) => {
if (room !== "admins") { if (room !== "admins") {
return messageResponse; return messageResponse;
@ -716,7 +741,7 @@ const processMessageObject = async (messageResponse, room) => {
} }
}; };
const renderNewMessages = (fetchMessages, existingIdentifiers, messagesContainer, room, mostRecentMessage) => { const renderNewMessages = async (fetchMessages, existingIdentifiers, messagesContainer, room, mostRecentMessage) => {
let firstNewMessageIdentifier = null; let firstNewMessageIdentifier = null;
let updatedMostRecentMessage = mostRecentMessage; let updatedMostRecentMessage = mostRecentMessage;
@ -727,7 +752,7 @@ const renderNewMessages = (fetchMessages, existingIdentifiers, messagesContainer
firstNewMessageIdentifier = message.identifier; firstNewMessageIdentifier = message.identifier;
} }
const messageHTML = buildMessageHTML(message, fetchMessages, room, isNewMessage); const messageHTML = await buildMessageHTML(message, fetchMessages, room, isNewMessage);
messagesContainer.insertAdjacentHTML('beforeend', messageHTML); messagesContainer.insertAdjacentHTML('beforeend', messageHTML);
if (!updatedMostRecentMessage || new Date(message.timestamp) > new Date(updatedMostRecentMessage?.latestTimestamp || 0)) { if (!updatedMostRecentMessage || new Date(message.timestamp) > new Date(updatedMostRecentMessage?.latestTimestamp || 0)) {
@ -748,9 +773,9 @@ const isMessageNew = (message, mostRecentMessage) => {
return !mostRecentMessage || new Date(message.timestamp) > new Date(mostRecentMessage?.latestTimestamp); return !mostRecentMessage || new Date(message.timestamp) > new Date(mostRecentMessage?.latestTimestamp);
}; };
const buildMessageHTML = (message, fetchMessages, room, isNewMessage) => { const buildMessageHTML = async (message, fetchMessages, room, isNewMessage) => {
const replyHtml = buildReplyHtml(message, fetchMessages); const replyHtml = await buildReplyHtml(message, fetchMessages);
const attachmentHtml = buildAttachmentHtml(message, room); const attachmentHtml = await buildAttachmentHtml(message, room);
const avatarUrl = `/arbitrary/THUMBNAIL/${message.name}/qortal_avatar`; const avatarUrl = `/arbitrary/THUMBNAIL/${message.name}/qortal_avatar`;
return ` return `
@ -773,7 +798,7 @@ const buildMessageHTML = (message, fetchMessages, room, isNewMessage) => {
` `
} }
const buildReplyHtml = (message, fetchMessages) => { const buildReplyHtml = async (message, fetchMessages) => {
if (!message.replyTo) return "" if (!message.replyTo) return ""
const repliedMessage = fetchMessages.find(m => m && m.identifier === message.replyTo) const repliedMessage = fetchMessages.find(m => m && m.identifier === message.replyTo)
@ -787,23 +812,47 @@ const buildReplyHtml = (message, fetchMessages) => {
` `
} }
const buildAttachmentHtml = (message, room) => { const buildAttachmentHtml = async (message, room) => {
if (!message.attachments || message.attachments.length === 0) return "" if (!message.attachments || message.attachments.length === 0) {
return "";
}
return message.attachments.map(attachment => buildSingleAttachmentHtml(attachment, room)).join("") // Map over attachments -> array of Promises
} const attachmentsHtmlPromises = message.attachments.map(attachment =>
buildSingleAttachmentHtml(attachment, room)
);
const 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/')) { if (room !== "admins" && attachment.mimeType && attachment.mimeType.startsWith('image/')) {
const imageUrl = `/arbitrary/${attachment.service}/${attachment.name}/${attachment.identifier}` const imageUrl = `/arbitrary/${attachment.service}/${attachment.name}/${attachment.identifier}`
return ` return `
<div class="attachment"> <div class="attachment">
<img src="${imageUrl}" alt="${attachment.filename}" class="inline-image"/> <img src="${imageUrl}" alt="${attachment.filename}" class="inline-image"/>
<button onclick="fetchAndSaveAttachment('${attachment.service}', '${attachment.name}', '${attachment.identifier}', '${attachment.filename}', '${attachment.mimeType}')">
Save ${attachment.filename}
</button>
</div> </div>
` `
} else if } else if
(room === "admins" && attachment.mimeType && attachment.mimeType.startsWith('image/')) { (room === "admins" && attachment.mimeType && attachment.mimeType.startsWith('image/')) {
return fetchEncryptedImageHtml(attachment) // 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/png;base64,${decryptedBase64}`
return `
<div class="attachment">
<img src="${dataUrl}" alt="${attachment.filename}" class="inline-image"/>
<button onclick="fetchAndSaveAttachment('${attachment.service}', '${attachment.name}', '${attachment.identifier}', '${attachment.filename}', '${attachment.mimeType}')">
Save ${attachment.filename}
</button>
</div>
`;
} else { } else {
return ` return `

View File

@ -806,92 +806,138 @@ async function loadImageHtml(service, name, identifier, filename, mimeType) {
const fetchAndSaveAttachment = async (service, name, identifier, filename, mimeType) => { const fetchAndSaveAttachment = async (service, name, identifier, filename, mimeType) => {
try { try {
if (!filename || !mimeType) { if (!filename || !mimeType) {
console.error("Filename and mimeType are required"); console.error("Filename and mimeType are required");
return; return;
} }
let url = `${baseUrl}/arbitrary/${service}/${name}/${identifier}?async=true&attempts=5`
// If it's a private file, we fetch with ?encoding=base64 and decrypt
if (service === "MAIL_PRIVATE") { if (service === "MAIL_PRIVATE") {
service = "FILE_PRIVATE"; service = "FILE_PRIVATE";
} }
if (service === "FILE_PRIVATE") {
const urlPrivate = `${baseUrl}/arbitrary/${service}/${name}/${identifier}?encoding=base64&async=true&attempts=5` const baseUrlWithParams = `${baseUrl}/arbitrary/${service}/${name}/${identifier}?async=true&attempts=5`;
const response = await fetch(urlPrivate,{
method: 'GET', if (service === "FILE_PRIVATE") {
headers: { 'accept': 'text/plain' } // 1) We want the encrypted base64
}) const urlPrivate = `${baseUrlWithParams}&encoding=base64`;
if (!response.ok) { const response = await fetch(urlPrivate, {
throw new Error(`File not found (HTTP ${response.status}): ${urlPrivate}`) method: 'GET',
} headers: { 'accept': 'text/plain' }
});
const encryptedBase64Data = response
console.log("Fetched Base64 Data:", encryptedBase64Data)
// const sanitizedBase64 = encryptedBase64Data.replace(/[\r\n]+/g, '')
const decryptedData = await decryptObject(encryptedBase64Data)
console.log("Decrypted Data:", decryptedData);
const fileBlob = new Blob((decryptedData), { type: mimeType })
await qortalRequest({
action: "SAVE_FILE",
blob: fileBlob,
filename,
mimeType,
});
console.log("Encrypted file saved successfully:", filename)
} else {
const response = await fetch(url, {
method: 'GET',
headers: {'accept': 'text/plain'}
});
if (!response.ok) {
throw new Error(`File not found (HTTP ${response.status}): ${url}`)
}
const blob = await response.blob()
await qortalRequest({
action: "SAVE_FILE",
blob,
filename,
mimeType,
})
console.log("File saved successfully:", filename)
}
} catch (error) {
console.error(
`Error fetching or saving attachment (service: ${service}, name: ${name}, identifier: ${identifier}):`,
error
);
}
};
const fetchEncryptedImageHtml = async (service, name, identifier, filename, mimeType) => {
const urlPrivate = `${baseUrl}/arbitrary/${service}/${name}/${identifier}?encoding=base64&async=true&attempts=5`
const response = await fetch(urlPrivate,{
method: 'GET',
headers: { 'accept': 'text/plain' }
})
if (!response.ok) { if (!response.ok) {
throw new Error(`File not found (HTTP ${response.status}): ${urlPrivate}`) throw new Error(`File not found (HTTP ${response.status}): ${urlPrivate}`);
} }
//obtain the encrypted base64 of the image
const encryptedBase64Data = response // 2) Get the encrypted base64 text
console.log("Fetched Base64 Data:", encryptedBase64Data) const encryptedBase64Data = await response.text();
//decrypt the encrypted base64 object console.log("Fetched Encrypted Base64 Data:", encryptedBase64Data);
const decryptedData = await decryptObject(encryptedBase64Data)
console.log("Decrypted Data:", decryptedData); // 3) Decrypt => returns decrypted base64
//turn the decrypted object into a blob/uint8 array and specify mimetype //todo check if the uint8Array is needed or not. I am guessing not. const decryptedBase64 = await decryptObject(encryptedBase64Data);
const fileBlob = new Blob((decryptdData), { type: mimeType }) console.log("Decrypted Base64 Data:", decryptedBase64);
//create the URL for the decrypted file blob
const objectUrl = URL.createObjectURL(fileBlob) // 4) Convert that to a Blob
//create the HTML from the file blob URL. const fileBlob = base64ToBlob(decryptedBase64, mimeType);
const attachmentHtml = `<div class="attachment"><img src="${objectUrl}" alt="${filename}" class="inline-image"></div>`;
// 5) Save the file using qortalRequest
await qortalRequest({
action: "SAVE_FILE",
blob: fileBlob,
filename,
mimeType
});
console.log("Encrypted file saved successfully:", filename);
} else {
// Normal, unencrypted file
const response = await fetch(baseUrlWithParams, {
method: 'GET',
headers: { 'accept': 'text/plain' }
});
if (!response.ok) {
throw new Error(`File not found (HTTP ${response.status}): ${baseUrlWithParams}`);
}
const blob = await response.blob();
await qortalRequest({
action: "SAVE_FILE",
blob,
filename,
mimeType
});
console.log("File saved successfully:", filename);
}
} catch (error) {
console.error(
`Error fetching or saving attachment (service: ${service}, name: ${name}, identifier: ${identifier}):`,
error
);
}
};
return attachmentHtml /**
} * Convert a base64-encoded string into a Blob
* @param {string} base64String - The base64-encoded string (unencrypted)
* @param {string} mimeType - The MIME type of the file
* @returns {Blob} The resulting Blob
*/
const base64ToBlob = (base64String, mimeType) => {
// Decode base64 to binary string
const binaryString = atob(base64String);
// Convert binary string to Uint8Array
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
// Create a blob from the Uint8Array
return new Blob([bytes], { type: mimeType });
};
const fetchEncryptedImageBase64 = async (service, name, identifier, mimeType) => {
try {
// Fix potential typo: use &async=...
const urlPrivate = `${baseUrl}/arbitrary/${service}/${name}/${identifier}?encoding=base64&async=true&attempts=5`;
const response = await fetch(urlPrivate, {
method: 'GET',
headers: { 'accept': 'text/plain' }
});
if (!response.ok) {
// Return null to "skip" the missing file
console.warn(`File not found (HTTP ${response.status}): ${urlPrivate}`);
return null;
}
// 2) Read the base64 text
const encryptedBase64Data = await response.text();
console.log("Fetched Encrypted Base64 Data:", encryptedBase64Data);
// 3) Decrypt => returns the *decrypted* base64 string
const decryptedBase64 = await decryptObject(encryptedBase64Data);
console.log("Decrypted Base64 Data:", decryptedBase64);
// 4) Convert that decrypted base64 into a Blob
const fileBlob = base64ToBlob(decryptedBase64, mimeType);
// 5) (Optional) Create an object URL
const objectUrl = URL.createObjectURL(fileBlob);
console.log("Object URL:", objectUrl);
// Return the base64 or objectUrl, whichever you need
return decryptedBase64;
} catch (error) {
console.error("Skipping file due to error in fetchEncryptedImageBase64:", error);
return null; // indicates "missing or failed"
}
};
const renderData = async (service, name, identifier) => { const renderData = async (service, name, identifier) => {

View File

@ -68,7 +68,7 @@
<img src="assets/images/again-edited-qortal-minting-icon-156x156.png" alt=""> <img src="assets/images/again-edited-qortal-minting-icon-156x156.png" alt="">
</a> </a>
</span> </span>
<span class="navbar-caption-wrap"><a class="navbar-caption text-primary display-4" href="index.html">Q-Mintership Alpha v0.56b<br></a></span> <span class="navbar-caption-wrap"><a class="navbar-caption text-primary display-4" href="index.html">Q-Mintership Alpha v0.6b<br></a></span>
</div> </div>
<ul class="navbar-nav nav-dropdown" data-app-modern-menu="true"><li class="nav-item"><a class="nav-link link text-primary display-7" href="MINTERSHIP-FORUM"></a></li></ul> <ul class="navbar-nav nav-dropdown" data-app-modern-menu="true"><li class="nav-item"><a class="nav-link link text-primary display-7" href="MINTERSHIP-FORUM"></a></li></ul>
@ -197,6 +197,23 @@
<section data-bs-version="5.1" class="content7 boldm5 cid-uufIRKtXOO" id="content7-6"> <section data-bs-version="5.1" class="content7 boldm5 cid-uufIRKtXOO" id="content7-6">
<div class="container">
<div class="row">
<div class="col-12 col-lg-7 card">
<div class="title-wrapper">
<h2 class="mbr-section-title mbr-fonts-style display-2">
Huge changes and fixes 12-23-2024</h2>
</div>
</div>
<div class="col-12 col-lg-5 card">
<div class="text-wrapper">
<p class="mbr-text mbr-fonts-style display-7">
All of the Board and Forum bugs that were known have been resolved. There are still future changes to be made in order to add searching, etc. ALL IDENTIFIERS ARE SET TO PERMANENT IDENTIFIERS NOW. This means that all previous publishes that were done in testMode will no longer show up, and the app is now in 'officially released' status. Announcements about the MinterBoard can now be made publicly. Admin Board now allows publishing non-name-based cards with 'topics' instead, for use in admin voting. Encrypted images and attachments now work and download/display as they do in the unencrypted rooms on the forum. Much more to come, and much more has been done. </p>
</div>
</div>
</div>
</div>
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col-12 col-lg-7 card"> <div class="col-12 col-lg-7 card">
@ -208,7 +225,7 @@
<div class="col-12 col-lg-5 card"> <div class="col-12 col-lg-5 card">
<div class="text-wrapper"> <div class="text-wrapper">
<p class="mbr-text mbr-fonts-style display-7"> <p class="mbr-text mbr-fonts-style display-7">
The Minter and Admin boards received large updates today. Many modificatons. It should be about time to do public announcements about the Minter Board. New types of boards are in planning as well. The changes to the Mintership Forum Admins Room to allow encrypted file downloads are taking a bit longer than expected, but should be completed soon as well. Hopefully if all goes well the public announcements will take place on Monday. </a></p> The Minter and Admin boards received large updates today. Many modificatons. It should be about time to do public announcements about the Minter Board. New types of boards are in planning as well. The changes to the Mintership Forum Admins Room to allow encrypted file downloads are taking a bit longer than expected, but should be completed soon as well. Hopefully if all goes well the public announcements will take place on Monday.</p>
</div> </div>
</div> </div>
</div> </div>
@ -281,7 +298,7 @@
</div> </div>
<a class="link-wrap" href="#"> <a class="link-wrap" href="#">
<p class="mbr-link mbr-fonts-style display-4">Q-Mintership v0.56beta</p> <p class="mbr-link mbr-fonts-style display-4">Q-Mintership v0.6beta</p>
</a> </a>
</div> </div>
<div class="col-12 col-lg-6"> <div class="col-12 col-lg-6">