Compare commits

...

6 Commits

10 changed files with 624 additions and 202 deletions

2
.gitignore vendored
View File

@ -1,2 +0,0 @@
/.vscode
/.sync*

Binary file not shown.

Binary file not shown.

49
README.md Normal file
View File

@ -0,0 +1,49 @@
### Q-Mintership-Alpha
Q-Mintership-Alpha is the currently utilized version of the Q-Mintership app published on qortal://APP/Q-Mintership.
As of Feb 27 2025 Q-Mintership-Alpha is still the published and utilized version of the app.
#### Q-Mintership's 'MinterBoard'
The MinterBoard of Q-Mintership, is the primary location for users to publish 'cards' with information about themselves, links to things they have published on QDN, etc... and obtain minting rights from the Minter Admins.
- Cards are created by any non-minter (either previous minter no longer in the group, or new accounts without minting rights).
- Existing community members, existing minters, and Minter Admins, can vote/comment on the cards.
- Once a card has obtained the minimum required number of admin votes (40% of the Minter Admin count), the card will then display additional features to those that have the rights to see them. (Minter Admins, and 'Forum Admins', however 'Forum Admins' cannot actually make use of the functionality, they are only able to view it for development purposes.)
- The Minter Admins then initiate a PENDING GROUP_INVITE transaction.
- The Minter Admins are then able to issue GROUP_APPROVAL transactions to approve the invite.
- Once the required number of GROUP_APPROVAL transactions have been created, the GROUP_INVITE is no longer pending, and is active.
- The would-be minter that published a card, can then see a new 'JOIN_GROUP' button on their card upon returning to the MinterBoard.
- The user will then JOIN_GROUP to the MINTER group, ID 694. Thus allowing the ability to mint.
- Upon joining the MINTER group, the user will then have the ability to create a MINTING KEY (the same way as it was created prior to the Mintership concept, however now it no longer requires users to be level 1, only requirement now is to be part of the MINTER group)
- User then assigns their key to their node, and starts minting.
#### Q-Mintership 'AdminBoard'
- The AdminBoard is a separate board, encrypted to admins only, meant to be utilized for private decision-making between the admins.
- The AdminBoard was also adapted to allow REMOVAL of MINTER group members, via GROUP_APPROVAL from the Minter Admins.
- The REMOVAL functionality, at the moment, is private. Meaning only the admins that have access to the AdminBoard, can see the data. This will be changed in the future, and a new location where the data will be able to be seen publicly, will be created.
#### Q-Mintership Forum
- The Forum portion of Q-Mintership is a public (and private) forum, allowing communications to take place in the fashion of long-term forum messages, replies, etc.
- Publishing of images with previews, and various 'attachments' with data is also possible on the forum.
- The forum has two public rooms by default, and one private room. General and Minter rooms are public, and Admin room is private.
- The forum will be getting extensive updates in the future, and the Minter room will be made a publicly VIEWABLE room, but only able to be published to by MINTERS.
#### Q-Mintership MAM Board
- The MAM Board (or ARBoard in the code) is built to allow the adding and removal of Minter Admins from the MINTER group. Proposals for additions or removals of certain accounts from and to the Minter takes place here.
- This board also displays a list of the current Minter Admins, and has the ability to propose a removal of that user with a propose removal button.
#### Additional
Many additional features and functions are planned for Q-Mintership, and increased performance and more will be added as time goes on.
Longer-term the plan is to re-write the app into React+TypeScript, which will make it MUCH faster and able to accommodate much more, with a component-based development style similar to that of the other React-based applications on Qortal (Q-Tube, Q-Blog, Q-Mail, etc.)
A fully featured data viewer and explorer function will be built into Q-Mintership in the future, along with a comprehensive notification system, and more.

View File

@ -584,7 +584,7 @@ body {
border-radius: 5px; border-radius: 5px;
padding: 20px; padding: 20px;
margin: 20px auto; /* center horizontally */ margin: 20px auto; /* center horizontally */
max-width: 600px; /* limit width */ /* max-width: 600px; */
color: #ddd; /* text color */ color: #ddd; /* text color */
text-align: center; text-align: center;
align-items: center; align-items: center;
@ -596,7 +596,7 @@ body {
background-color:#000000; background-color:#000000;
width: 90%; width: 90%;
font-size: 1.8rem; font-size: 1.8rem;
color: #4d0000; color: #fff3f3;
text-align: center; text-align: center;
align-items: center; align-items: center;
/* you could style the list items or bullet if you like */ /* you could style the list items or bullet if you like */
@ -616,7 +616,17 @@ body {
background-color: #14161a; background-color: #14161a;
border: 1px solid #8caeb0; border: 1px solid #8caeb0;
border-radius: 4px; border-radius: 4px;
color: #5c0101; color: #f19c9c;
}
.invite-form input.invite-input {
padding: 1rem;
font-size: 2rem;
line-height: 2;
background-color: #14161a;
border: 1px solid #8caeb0;
border-radius: 4px;
color: #dddddd;
} }
.publish-card-button { .publish-card-button {
@ -707,6 +717,43 @@ body {
background-color: #281e1e; background-color: #281e1e;
} }
.approve-invite-list-button {
background-color: rgba(32, 88, 34, 0.554);
color: #fff;
border: none;
border-radius: 1vw;
padding: 1vh,2vh;
cursor: pointer;
font-size: 1.1rem;
transition: background-color 0.2s ease;
}
.approve-invite-list-button:hover {
background-color: rgba(34, 186, 47, 0.84); /* a darker variant */
}
.invite-approvals strong {
display: inline-block;
}
.invite-item {
margin-bottom: 0.5em;
background-color: rgba(31, 31, 31, 0.595);
border: 1px solid #444;
border-radius: 6px;
color: #ccc;
padding: 1rem;
}
/* Top row: use flex for horizontal arrangement */
.invite-top-row {
display: flex;
background-color:#173c52ae;
flex-wrap: wrap;
gap: 1rem;
align-items: center;
justify-content: center;
}
/* Responsive Design */ /* Responsive Design */
@media (max-width: 768px) { @media (max-width: 768px) {

View File

@ -33,7 +33,7 @@ const loadMinterAdminToolsPage = async () => {
<div id="tools-submenu" class="tools-submenu"> <div id="tools-submenu" class="tools-submenu">
<div class="tools-buttons" style="display: flex; gap: 1em; justify-content: center;"> <div class="tools-buttons" style="display: flex; gap: 1em; justify-content: center;">
<button id="toggle-blocklist-button" class="publish-card-button">Add/Remove blockedUsers</button> <button id="toggle-blocklist-button" class="publish-card-button">Add/Remove blockedUsers</button>
<button id="create-group-invite" class="publish-card-button">Create Pending Group Invite</button> <button id="create-group-invite" class="publish-card-button" style="backgroundColor:rgb(82, 114, 145)">Create and Display Pending Group Invites</button>
</div> </div>
<div id="tools-window" class="tools-window" style="margin-top: 2em;"> <div id="tools-window" class="tools-window" style="margin-top: 2em;">
@ -55,6 +55,30 @@ const loadMinterAdminToolsPage = async () => {
<button id="blocklist-remove-button" class="publish-card-button">Remove</button> <button id="blocklist-remove-button" class="publish-card-button">Remove</button>
</div> </div>
</div> </div>
<div id="invite-container" class="invite-form" style="display: none; flex-direction: column; padding: 0.75em; align-items: center; justify-content: center;">
<!-- Existing pending invites display -->
<div id="pending-invites-display" class="pending-invites-display" style="margin-bottom: 1em;">
<!-- We will fill this dynamically with a list/table of pending invites -->
</div>
<!-- Input for name/address -->
<h3 style="margin-top: 0;">Manual Group Invite</h3>
<input
type="text"
id="invite-input"
class="invite-input"
placeholder="Enter name or address to invite"
style="margin-bottom: 1em;"
/>
<!-- Button to create the invite transaction -->
<div class="invite-button-container publish-card-form">
<button id="invite-user-button" class="publish-card-button">Invite User</button>
</div>
</div>
</div> </div>
</div> </div>
@ -63,10 +87,10 @@ const loadMinterAdminToolsPage = async () => {
document.body.appendChild(mainContent) document.body.appendChild(mainContent)
addToolsPageEventListeners() await addToolsPageEventListeners()
} }
function addToolsPageEventListeners() { const addToolsPageEventListeners= async () => {
document.getElementById("toggle-blocklist-button").addEventListener("click", async () => { document.getElementById("toggle-blocklist-button").addEventListener("click", async () => {
const container = document.getElementById("blocklist-container") const container = document.getElementById("blocklist-container")
// toggle show/hide // toggle show/hide
@ -116,6 +140,32 @@ function addToolsPageEventListeners() {
alert(`"${nameToRemove}" removed from the block list (if it was present).`) alert(`"${nameToRemove}" removed from the block list (if it was present).`)
}) })
document.getElementById("invite-user-button").addEventListener("click", async () => {
const inviteInput = document.getElementById("invite-input")
const nameOrAddress = inviteInput.value.trim()
if (!nameOrAddress) return
try {
// We'll call some function handleManualInvite(nameOrAddress)
await handleManualInvite(nameOrAddress)
inviteInput.value = ""
} catch (err) {
console.error("Error inviting user:", err)
alert("Failed to invite user.")
}
})
document.getElementById("create-group-invite").addEventListener("click", async () => {
const inviteContainer = document.getElementById("invite-container")
// Toggle display
inviteContainer.style.display = (inviteContainer.style.display === "none" ? "flex" : "none")
// If showing, load the pending invites
if (inviteContainer.style.display === "flex") {
const pendingInvites = await fetchPendingInvites()
await displayPendingInviteDetails(pendingInvites)
}
})
} }
const displayBlockList = (blockedNames) => { const displayBlockList = (blockedNames) => {
@ -131,4 +181,139 @@ const displayBlockList = (blockedNames) => {
` `
} }
const fetchPendingInvites = async () => {
try {
const { finalInviteTxs, pendingInviteTxs } = await fetchAllInviteTransactions()
return pendingInviteTxs
} catch (err) {
console.error("Error fetching pending invites:", err)
return []
}
}
const handleManualInvite = async (nameOrAddress) => {
const addressInfo = await getAddressInfo(nameOrAddress)
let address = addressInfo.address
if (addressInfo && address) {
console.log(`address is ${address}`)
} else {
// it might be a Qortal name => getNameInfo
const nameData = await getNameInfo(nameOrAddress)
if (!nameData || !nameData.owner) {
throw new Error(`Cannot find valid address for ${nameOrAddress}`)
}
address = nameData.owner
}
const adminPublicKey = await getPublicKeyByName(userState.accountName)
const timeToLive = 864000 // e.g. 10 days in seconds
const fee = 0.01
let txGroupId = 694
// build the raw invite transaction
const rawInviteTransaction = await createGroupInviteTransaction(
address,
adminPublicKey,
694,
address,
timeToLive,
txGroupId,
fee
)
// sign
const signedTransaction = await qortalRequest({
action: "SIGN_TRANSACTION",
unsignedBytes: rawInviteTransaction
})
if (!signedTransaction) {
throw new Error("SIGN_TRANSACTION returned null. Possibly user canceled or an older UI?")
}
// process
const processResponse = await processTransaction(signedTransaction)
if (!processResponse) {
throw new Error("Failed to process transaction. Possibly canceled or error from Qortal Core.")
}
alert(`Invite transaction submitted for ${nameOrAddress}. Wait for confirmation.`)
}
const displayPendingInviteDetails = async (pendingInvites) => {
const invitesContainer = document.getElementById('pending-invites-display')
if (!pendingInvites || pendingInvites.length === 0) {
invitesContainer.innerHTML = "<p>No pending invites found.</p>"
return
}
let html = `<h4>Current Pending Invites:</h4><div class="pending-invites-list">`
for (const inviteTx of pendingInvites) {
const inviteeAddress = inviteTx.invitee
const dateStr = new Date(inviteTx.timestamp).toLocaleString()
let inviteeName = ""
const txSig = inviteTx.signature
const creatorName = await getNameFromAddress(inviteTx.creatorAddress)
if (!creatorName) {
creatorName = inviteTx.creatorAddress
}
try {
// fetch the name from address, if it fails we keep it blank or fallback to the address
inviteeName = await getNameFromAddress(inviteeAddress)
if (!inviteeName || inviteeName === inviteeAddress) {
inviteeName = inviteeAddress // fallback
}
} catch (err) {
inviteeName = inviteeAddress // fallback if getName fails
}
const approvalSearchResults = await searchTransactions({
txTypes: ['GROUP_APPROVAL'],
confirmationStatus: 'CONFIRMED',
limit: 0,
reverse: false,
offset: 0,
startBlock: 1990000,
blockLimit: 0,
txGroupId: 0
})
const approvals = approvalSearchResults.filter(
(approvalTx) => approvalTx.pendingSignature === txSig
)
const { tableHtml, approvalCount = approvals.length } = await buildApprovalTableHtml(approvals, getNameFromAddress)
const finalTable = approvals.length > 0 ? tableHtml : "<p>No Approvals Found</p>"
html += `
<div class="invite-item">
<div class="invite-top-row">
<span><strong>Invite Tx</strong>:<p style="color:lightblue"> ${inviteTx.signature.slice(0, 8)}...</p></span>
<span> <strong>Invitee</strong>:<p style="color:lightblue"> ${inviteeName}</p></span>
<span> <strong>Date</strong>:<p style="color:lightblue"> ${dateStr}</p></span>
<span> <strong>CreatorName</strong>:<p style="color:lightblue"> ${creatorName}</p></span>
<span> <strong>Total Approvals</strong>:<p style="color:lightblue"> ${approvalCount}</p></span>
</div>
<!-- Next line for approvals -->
<div class="invite-approvals">
<strong>Existing Approvals:</strong>
${finalTable}
</div>
<button
class="approve-invite-list-button"
onclick="handleGroupApproval('${inviteTx.signature}')"
>
Approve Invite
</button>
</div>
`
}
html += "</div>"
invitesContainer.innerHTML = html
}

View File

@ -9,6 +9,8 @@ const GROUP_APPROVAL_FEATURE_TRIGGER_HEIGHT = 2012800 //TODO update this to corr
let featureTriggerPassed = false let featureTriggerPassed = false
let isApproved = false let isApproved = false
let cachedMinterAdmins
let cachedMinterGroup
const loadMinterBoardPage = async () => { const loadMinterBoardPage = async () => {
// Clear existing content on the page // Clear existing content on the page
@ -207,11 +209,42 @@ const loadMinterBoardPage = async () => {
await loadCards(minterCardIdentifierPrefix) await loadCards(minterCardIdentifierPrefix)
}) })
} }
//Initialize Minter Group and Admin Group
await initializeCachedGroups()
await featureTriggerCheck() await featureTriggerCheck()
await loadCards(minterCardIdentifierPrefix) await loadCards(minterCardIdentifierPrefix)
} }
const initializeCachedGroups = async () => {
try {
const [minterGroup, minterAdmins] = await Promise.all([
fetchMinterGroupMembers(),
fetchMinterGroupAdmins()
])
cachedMinterGroup = minterGroup
cachedMinterAdmins = minterAdmins
} catch (error) {
console.error("Error initializing cached groups:", error)
}
}
const runWithConcurrency = async (tasks, concurrency = 5) => {
const results = []
let index = 0
const workers = new Array(concurrency).fill(null).map(async () => {
while (index < tasks.length) {
const currentIndex = index++
const task = tasks[currentIndex]
results[currentIndex] = await task()
}
})
await Promise.all(workers)
return results
}
const extractMinterCardsMinterName = async (cardIdentifier) => { const extractMinterCardsMinterName = async (cardIdentifier) => {
// Ensure the identifier starts with the prefix // Ensure the identifier starts with the prefix
@ -428,222 +461,236 @@ const processARBoardCards = async (allValidCards) => {
//Main function to load the Minter Cards ---------------------------------------- //Main function to load the Minter Cards ----------------------------------------
const loadCards = async (cardIdentifierPrefix) => { const loadCards = async (cardIdentifierPrefix) => {
if ((!cachedMinterGroup || cachedMinterGroup.length === 0) || (!cachedMinterAdmins || cachedMinterAdmins.length === 0)) {
await initializeCachedGroups()
}
const cardsContainer = document.getElementById("cards-container") const cardsContainer = document.getElementById("cards-container")
let isARBoard = false
cardsContainer.innerHTML = "<p>Loading cards...</p>" cardsContainer.innerHTML = "<p>Loading cards...</p>"
const counterSpan = document.getElementById("board-card-counter") const counterSpan = document.getElementById("board-card-counter")
if (counterSpan) counterSpan.textContent = "(loading...)"
if (counterSpan) { const isARBoard = cardIdentifierPrefix.startsWith("QM-AR-card")
// Clear or show "Loading..."
counterSpan.textContent = "(loading...)"
}
if (cardIdentifierPrefix.startsWith("QM-AR-card")) {
isARBoard = true
console.warn(`ARBoard determined:`, isARBoard)
}
let afterTime = 0
const timeRangeSelect = document.getElementById("time-range-select")
const showExistingCheckbox = document.getElementById("show-existing-checkbox") const showExistingCheckbox = document.getElementById("show-existing-checkbox")
const showExisting = showExistingCheckbox && showExistingCheckbox.checked const showExisting = showExistingCheckbox && showExistingCheckbox.checked
let afterTime = 0
const timeRangeSelect = document.getElementById("time-range-select")
if (timeRangeSelect) { if (timeRangeSelect) {
const days = parseInt(timeRangeSelect.value, 10) const days = parseInt(timeRangeSelect.value, 10)
if (days > 0) { if (days > 0) {
const now = Date.now() const now = Date.now()
const dayMs = 24 * 60 * 60 * 1000 afterTime = now - days * 24 * 60 * 60 * 1000
afterTime = now - days * dayMs // e.g. last X days
console.log(`afterTime for last ${days} days = ${new Date(afterTime).toLocaleString()}`)
} }
} }
try { try {
// 1) Fetch raw "BLOG_POST" entries const rawResults = await searchSimple('BLOG_POST', cardIdentifierPrefix, '', 0, 0, '', false, true, afterTime)
const response = await searchSimple('BLOG_POST', cardIdentifierPrefix, '', 0, 0, '', false, true, afterTime) if (!rawResults || !Array.isArray(rawResults) || rawResults.length === 0) {
if (!response || !Array.isArray(response) || response.length === 0) {
cardsContainer.innerHTML = "<p>No cards found.</p>" cardsContainer.innerHTML = "<p>No cards found.</p>"
return return
} }
// 2) Validate structure
const validatedCards = await Promise.all(
response.map(async (card) => {
const isValid = await validateCardStructure(card)
return isValid ? card : null
})
)
const validCards = validatedCards.filter((card) => card !== null)
if (validCards.length === 0) { const validated = (await Promise.all(
rawResults.map(async (r) => (await validateCardStructure(r)) ? r : null)
)).filter(Boolean)
if (validated.length === 0) {
cardsContainer.innerHTML = "<p>No valid cards found.</p>" cardsContainer.innerHTML = "<p>No valid cards found.</p>"
return return
} }
// Additional logic for ARBoard or MinterCards
const finalCards = isARBoard
? await processARBoardCards(validCards)
: await processMinterBoardCards(validCards)
// Sort finalCards according to selectedSort let processedCards
let selectedSort = 'newest' if (isARBoard) {
const sortSelect = document.getElementById('sort-select') processedCards = await processARBoardCards(validated)
} else {
processedCards = await processMinterBoardCards(validated)
}
let selectedSort = "newest"
const sortSelect = document.getElementById("sort-select")
if (sortSelect) { if (sortSelect) {
selectedSort = sortSelect.value selectedSort = sortSelect.value
} }
if (selectedSort === "name") {
if (selectedSort === 'name') { processedCards.sort((a, b) => (a.name||"").localeCompare(b.name||""))
finalCards.sort((a, b) => {
const nameA = a.name?.toLowerCase() || ''
const nameB = b.name?.toLowerCase() || ''
return nameA.localeCompare(nameB)
})
} else if (selectedSort === 'recent-comments') { } else if (selectedSort === 'recent-comments') {
// If you need the newest comment timestamp // If you need the newest comment timestamp
for (let card of finalCards) { for (let card of finalCards) {
card.newestCommentTimestamp = await getNewestCommentTimestamp(card.identifier) card.newestCommentTimestamp = await getNewestCommentTimestamp(card.identifier)
}
finalCards.sort((a, b) =>
(b.newestCommentTimestamp || 0) - (a.newestCommentTimestamp || 0)
)
} else if (selectedSort === 'least-votes') {
await applyVoteSortingData(finalCards, /* ascending= */ true)
} else if (selectedSort === 'most-votes') {
await applyVoteSortingData(finalCards, /* ascending= */ false)
} }
// else 'newest' => do nothing (already sorted newest-first by your process functions). finalCards.sort((a, b) =>
// Create the 'finalCardsArray' that includes the data, etc. (b.newestCommentTimestamp || 0) - (a.newestCommentTimestamp || 0)
let finalCardsArray = [] )
let alreadyMinterCards = [] } else if (selectedSort === 'least-votes') {
cardsContainer.innerHTML = '' await applyVoteSortingData(finalCards, /* ascending= */ true)
for (const card of finalCards) { } else if (selectedSort === 'most-votes') {
try { await applyVoteSortingData(finalCards, /* ascending= */ false)
const skeletonHTML = createSkeletonCardHTML(card.identifier) }
cardsContainer.insertAdjacentHTML("beforeend", skeletonHTML)
const cardDataResponse = await qortalRequest({
action: "FETCH_QDN_RESOURCE",
name: card.name,
service: "BLOG_POST",
identifier: card.identifier
})
if (!cardDataResponse || !cardDataResponse.poll) { cardsContainer.innerHTML = "" // reset
// skip for (const card of processedCards) {
console.warn(`Skipping card: missing data/poll. identifier=${card.identifier}`) const skeletonHTML = createSkeletonCardHTML(card.identifier)
removeSkeleton(card.identifier) cardsContainer.insertAdjacentHTML("beforeend", skeletonHTML)
continue }
const finalCardsArray = []
const alreadyMinterCards = []
const tasks = processedCards.map(card => {
return async () => {
// We'll store an object with skip info, QDN data, etc.
const result = {
card,
skip: false,
skipReason: "",
isAlreadyMinter: false,
cardData: null,
} }
// Extra validation: check poll ownership matches card publisher
const pollPublisherAddress = await getPollOwnerAddress(cardDataResponse.poll) try {
const cardPublisherAddress = await fetchOwnerAddressFromName(card.name) const data = await qortalRequest({
if (pollPublisherAddress !== cardPublisherAddress) { action: "FETCH_QDN_RESOURCE",
console.warn(`Poll hijack attack found, discarding card ${card.identifier}`) name: card.name,
removeSkeleton(card.identifier) service: "BLOG_POST",
continue identifier: card.identifier
} })
// If ARBoard, do a quick address check if (!data || !data.poll) {
if (isARBoard) { result.skip = true
const ok = await verifyMinter(cardDataResponse.minterName) result.skipReason = "Missing or invalid poll"
if (!ok) { return result
console.warn(`Card is not a minter nor an admin, not including in ARBoard. identifier: ${card.identifier}`)
removeSkeleton(card.identifier)
continue
} }
} else {
const isAlreadyMinter = await verifyMinter(cardDataResponse.creator) const pollPublisherAddress = await getPollOwnerAddressCached(data.poll)
if (isAlreadyMinter) { const cardPublisherAddress = await fetchOwnerAddressFromNameCached(card.name)
console.warn(`card IS ALREADY a minter, adding to alreadyMinterCards array: ${card.identifier}`) if (pollPublisherAddress !== cardPublisherAddress) {
removeSkeleton(card.identifier) result.skip = true
alreadyMinterCards.push({ result.skipReason = "Poll hijack mismatch"
...card, return result
cardDataResponse,
pollPublisherAddress,
cardPublisherAddress
})
continue
} }
// ARBoard => verify user is minter/admin
if (isARBoard) {
const ok = await verifyMinterCached(data.minterName)
if (!ok) {
result.skip = true
result.skipReason = "Card user not minter => skip from ARBoard"
return result
}
} else {
// MinterBoard => skip if user is minter
const isAlready = await verifyMinterCached(data.creator)
if (isAlready) {
result.skip = true
result.skipReason = "Already a minter"
result.isAlreadyMinter = true
result.cardData = data
return result
}
}
// If we get here => it's a keeper
result.cardData = data
} catch (err) {
console.warn("Error fetching resource or skip logic:", err)
result.skip = true
result.skipReason = "Error: " + err
} }
// **Push** to finalCardsArray for further processing (duplicates, etc.)
return result
}
})
// ADJUST THE CONCURRENCY TO INCREASE THE AMOUNT OF CARDS PROCESSED AT ONCE. INCREASE UNTIL THERE ARE ISSUES.
const concurrency = 30
const results = await runWithConcurrency(tasks, concurrency)
// Fill final arrays
for (const r of results) {
if (r.skip && r.isAlreadyMinter) {
alreadyMinterCards.push({ ...r.card, cardDataResponse: r.cardData })
removeSkeleton(r.card.identifier)
} else if (r.skip) {
console.warn(`Skipping card ${r.card.identifier}, reason=${r.skipReason}`)
removeSkeleton(r.card.identifier)
} else {
// keeper
finalCardsArray.push({ finalCardsArray.push({
...card, ...r.card,
cardDataResponse, cardDataResponse: r.cardData
pollPublisherAddress,
cardPublisherAddress,
}) })
if (counterSpan) {
const displayedCount = finalCardsArray.length
const alreadyMinterCount = alreadyMinterCards.length
// If you want to show both
counterSpan.textContent = `(${displayedCount} cards, ${alreadyMinterCount} existingMinters)`
}
} catch (err) {
console.error(`Error preparing card ${card.identifier}`, err)
removeSkeleton(card.identifier)
} }
} }
// Next, do the actual rendering:
// cardsContainer.innerHTML = ""
for (const cardObj of finalCardsArray) { for (const cardObj of finalCardsArray) {
// Insert a skeleton first if you like try {
// const skeletonHTML = createSkeletonCardHTML(cardObj.identifier) const pollResults = await fetchPollResultsCached(cardObj.cardDataResponse.poll)
// cardsContainer.insertAdjacentHTML("beforeend", skeletonHTML) const commentCount = await countCommentsCached(cardObj.identifier)
// Build final HTML const cardUpdatedTime = cardObj.updated || cardObj.created || null
const pollResults = await fetchPollResults(cardObj.cardDataResponse.poll) const bgColor = generateDarkPastelBackgroundBy(cardObj.name)
const commentCount = await countComments(cardObj.identifier)
const cardUpdatedTime = cardObj.updated || null
const bgColor = generateDarkPastelBackgroundBy(cardObj.name)
// Construct the final HTML for each card
const finalCardHTML = isARBoard
? await createARCardHTML(
cardObj.cardDataResponse,
pollResults,
cardObj.identifier,
commentCount,
cardUpdatedTime,
bgColor,
cardObj.cardPublisherAddress,
cardObj.isDuplicate
)
: await createCardHTML(
cardObj.cardDataResponse,
pollResults,
cardObj.identifier,
commentCount,
cardUpdatedTime,
bgColor,
cardObj.cardPublisherAddress
)
replaceSkeleton(cardObj.identifier, finalCardHTML) // If ARBoard => createARCardHTML else createCardHTML
const finalCardHTML = isARBoard
? await createARCardHTML(
cardObj.cardDataResponse,
pollResults,
cardObj.identifier,
commentCount,
cardUpdatedTime,
bgColor,
await fetchOwnerAddressFromNameCached(cardObj.name),
cardObj.isDuplicate
)
: await createCardHTML(
cardObj.cardDataResponse,
pollResults,
cardObj.identifier,
commentCount,
cardUpdatedTime,
bgColor,
await fetchOwnerAddressFromNameCached(cardObj.name)
)
replaceSkeleton(cardObj.identifier, finalCardHTML)
} catch (err) {
console.error(`Error finalizing card ${cardObj.identifier}:`, err)
removeSkeleton(cardObj.identifier)
}
} }
if (showExisting && alreadyMinterCards.length > 0) { if (showExisting && alreadyMinterCards.length > 0) {
console.warn(`Rendering Existing Minter cards because user selected showExisting`) console.log(`Rendering minted cards because showExisting is checked, count=${alreadyMinterCards.length}`)
for (const minted of alreadyMinterCards) {
for (const mintedCardObj of alreadyMinterCards) { const skeletonHTML = createSkeletonCardHTML(minted.identifier)
const skeletonHTML = createSkeletonCardHTML(mintedCardObj.identifier)
cardsContainer.insertAdjacentHTML("beforeend", skeletonHTML) cardsContainer.insertAdjacentHTML("beforeend", skeletonHTML)
const pollResults = await fetchPollResults(mintedCardObj.cardDataResponse.poll) try {
const commentCount = await countComments(mintedCardObj.identifier) const pollResults = await fetchPollResultsCached(minted.cardDataResponse.poll)
const cardUpdatedTime = mintedCardObj.updated || null const commentCount = await countCommentsCached(minted.identifier)
const bgColor = generateDarkPastelBackgroundBy(mintedCardObj.name) const cardUpdatedTime = minted.updated || minted.created || null
const isExistingMinter = true const bgColor = generateDarkPastelBackgroundBy(minted.name)
const finalCardHTML = await createCardHTML(
const finalCardHTML = await createCardHTML( minted.cardDataResponse,
mintedCardObj.cardDataResponse, pollResults,
pollResults, minted.identifier,
mintedCardObj.identifier, commentCount,
commentCount, cardUpdatedTime,
cardUpdatedTime, bgColor,
bgColor, await fetchOwnerAddressFromNameCached(minted.name),
mintedCardObj.cardPublisherAddress, /* isExistingMinter= */ true
isExistingMinter )
) replaceSkeleton(minted.identifier, finalCardHTML)
replaceSkeleton(mintedCardObj.identifier, finalCardHTML) } catch (err) {
console.error(`Error finalizing minted card ${minted.identifier}:`, err)
removeSkeleton(minted.identifier)
}
} }
} }
if (counterSpan) {
const displayed = finalCardsArray.length
const minted = alreadyMinterCards.length
counterSpan.textContent = `(${displayed} displayed, ${minted} minters)`
}
} catch (error) { } catch (error) {
console.error("Error loading cards:", error) console.error("Error loading cards:", error)
cardsContainer.innerHTML = "<p>Failed to load cards.</p>" cardsContainer.innerHTML = "<p>Failed to load cards.</p>"
@ -653,9 +700,19 @@ const loadCards = async (cardIdentifierPrefix) => {
} }
} }
const verifyMinterCache = new Map()
const verifyMinterCached = async (nameOrAddress) => {
if (verifyMinterCache.has(nameOrAddress)) {
return verifyMinterCache.get(nameOrAddress)
}
const result = await verifyMinter(nameOrAddress)
verifyMinterCache.set(nameOrAddress, result)
return result
}
const verifyMinter = async (minterName) => { const verifyMinter = async (minterName) => {
try { try {
const nameInfo = await getNameInfo(minterName) const nameInfo = await getNameInfoCached(minterName)
if (!nameInfo) return false if (!nameInfo) return false
const minterAddress = nameInfo.owner const minterAddress = nameInfo.owner
@ -663,8 +720,10 @@ const verifyMinter = async (minterName) => {
if (!isValid) return false if (!isValid) return false
// Then check if they're in the minter group // Then check if they're in the minter group
const minterGroup = await fetchMinterGroupMembers() // const minterGroup = await fetchMinterGroupMembers()
const adminGroup = await fetchMinterGroupAdmins() const minterGroup = cachedMinterGroup
// const adminGroup = await fetchMinterGroupAdmins()
const adminGroup = cachedMinterAdmins
const minterGroupAddresses = minterGroup.map(m => m.member) const minterGroupAddresses = minterGroup.map(m => m.member)
const adminGroupAddresses = adminGroup.map(m => m.member) const adminGroupAddresses = adminGroup.map(m => m.member)
@ -677,8 +736,10 @@ const verifyMinter = async (minterName) => {
} }
const applyVoteSortingData = async (cards, ascending = true) => { const applyVoteSortingData = async (cards, ascending = true) => {
const minterGroupMembers = await fetchMinterGroupMembers() // const minterGroupMembers = await fetchMinterGroupMembers()
const minterAdmins = await fetchMinterGroupAdmins() const minterGroupMembers = cachedMinterGroup
// const minterAdmins = await fetchMinterGroupAdmins()
const minterAdmins = cachedMinterAdmins
for (const card of cards) { for (const card of cards) {
try { try {
@ -695,7 +756,7 @@ const applyVoteSortingData = async (cards, ascending = true) => {
card._minterYes = 0 card._minterYes = 0
continue continue
} }
const pollResults = await fetchPollResults(cardDataResponse.poll); const pollResults = await fetchPollResultsCached(cardDataResponse.poll);
const { adminYes, adminNo, minterYes, minterNo } = await processPollData( const { adminYes, adminNo, minterYes, minterNo } = await processPollData(
pollResults, pollResults,
minterGroupMembers, minterGroupMembers,
@ -866,7 +927,8 @@ const loadCardIntoForm = async (cardData) => {
// Main function to publish a new Minter Card ----------------------------------------------- // Main function to publish a new Minter Card -----------------------------------------------
const publishCard = async (cardIdentifierPrefix) => { const publishCard = async (cardIdentifierPrefix) => {
const minterGroupData = await fetchMinterGroupMembers() // const minterGroupData = await fetchMinterGroupMembers()
const minterGroupData = cachedMinterGroup
const minterGroupAddresses = minterGroupData.map(m => m.member) const minterGroupAddresses = minterGroupData.map(m => m.member)
const userAddress = userState.accountAddress const userAddress = userState.accountAddress
@ -1353,6 +1415,16 @@ const toggleComments = async (cardIdentifier) => {
} }
} }
const commentCountCache = new Map()
const countCommentsCached= async (cardIdentifier) => {
if (commentCountCache.has(cardIdentifier)) {
return commentCountCache.get(cardIdentifier)
}
const count = await countComments(cardIdentifier)
commentCountCache.set(cardIdentifier, count)
return count
}
const countComments = async (cardIdentifier) => { const countComments = async (cardIdentifier) => {
try { try {
const response = await searchSimple('BLOG_POST', `comment-${cardIdentifier}`, '', 0, 0, '', 'false') const response = await searchSimple('BLOG_POST', `comment-${cardIdentifier}`, '', 0, 0, '', 'false')
@ -1503,7 +1575,7 @@ const handleInviteMinter = async (minterName) => {
try { try {
const blockInfo = await getLatestBlockInfo() const blockInfo = await getLatestBlockInfo()
const blockHeight = blockInfo.height const blockHeight = blockInfo.height
const minterAccountInfo = await getNameInfo(minterName) const minterAccountInfo = await getNameInfoCached(minterName)
const minterAddress = await minterAccountInfo.owner const minterAddress = await minterAccountInfo.owner
let adminPublicKey let adminPublicKey
let txGroupId let txGroupId
@ -1587,7 +1659,8 @@ const featureTriggerCheck = async () => {
const checkAndDisplayInviteButton = async (adminYes, creator, cardIdentifier) => { const checkAndDisplayInviteButton = async (adminYes, creator, cardIdentifier) => {
const isSomeTypaAdmin = userState.isAdmin || userState.isMinterAdmin const isSomeTypaAdmin = userState.isAdmin || userState.isMinterAdmin
const isBlockPassed = await featureTriggerCheck() const isBlockPassed = await featureTriggerCheck()
const minterAdmins = await fetchMinterGroupAdmins() // const minterAdmins = await fetchMinterGroupAdmins()
const minterAdmins = cachedMinterAdmins
// default needed admin count = 9, or 40% if block has passed // default needed admin count = 9, or 40% if block has passed
let minAdminCount = 9 let minAdminCount = 9
@ -1603,7 +1676,7 @@ const checkAndDisplayInviteButton = async (adminYes, creator, cardIdentifier) =>
} }
console.log(`passed initial button creation checks (adminYes >= ${minAdminCount})`) console.log(`passed initial button creation checks (adminYes >= ${minAdminCount})`)
// get user's address from 'creator' name // get user's address from 'creator' name
const minterNameInfo = await getNameInfo(creator) const minterNameInfo = await getNameInfoCached(creator)
if (!minterNameInfo || !minterNameInfo.owner) { if (!minterNameInfo || !minterNameInfo.owner) {
console.warn(`No valid nameInfo for ${creator}, skipping invite button.`) console.warn(`No valid nameInfo for ${creator}, skipping invite button.`)
return null return null
@ -1652,10 +1725,8 @@ const checkAndDisplayInviteButton = async (adminYes, creator, cardIdentifier) =>
} }
} }
const findPendingApprovalTxForAddress = async (address, txType, limit = 0, offset = 0) => { const findPendingTxForAddress = async (address, txType, limit = 0, offset = 0) => {
// 1) Fetch all pending transactions
const pendingTxs = await searchPendingTransactions(limit, offset, false) const pendingTxs = await searchPendingTransactions(limit, offset, false)
// if a txType is passed, return the results related to that type, if not, then return any pending tx of the potential types.
let relevantTypes let relevantTypes
if (txType) { if (txType) {
relevantTypes = new Set([txType]) relevantTypes = new Set([txType])
@ -1692,7 +1763,8 @@ const checkGroupApprovalAndCreateButton = async (address, cardIdentifier, transa
// We are going to be verifying that the address isn't already a minter, before showing GROUP_APPROVAL buttons potentially... // We are going to be verifying that the address isn't already a minter, before showing GROUP_APPROVAL buttons potentially...
if (transactionType === "GROUP_INVITE") { if (transactionType === "GROUP_INVITE") {
console.log(`This is a GROUP_INVITE check for group approval... Checking that user isn't already a minter...`) console.log(`This is a GROUP_INVITE check for group approval... Checking that user isn't already a minter...`)
const minterMembers = await fetchMinterGroupMembers() // const minterMembers = await fetchMinterGroupMembers()
const minterMembers = cachedMinterGroup
const minterGroupAddresses = minterMembers.map(m => m.member) const minterGroupAddresses = minterMembers.map(m => m.member)
if (minterGroupAddresses.includes(address)) { if (minterGroupAddresses.includes(address)) {
console.warn(`User is already a minter, will not be creating group_approval buttons`) console.warn(`User is already a minter, will not be creating group_approval buttons`)
@ -1710,15 +1782,15 @@ const checkGroupApprovalAndCreateButton = async (address, cardIdentifier, transa
blockLimit: 0, blockLimit: 0,
txGroupId: 0 txGroupId: 0
}) })
const pendingApprovals = await findPendingApprovalTxForAddress(address, transactionType, 0, 0) const pendingTxs = await findPendingTxForAddress(address, transactionType, 0, 0)
let isSomeTypaAdmin = userState.isAdmin || userState.isMinterAdmin let isSomeTypaAdmin = userState.isAdmin || userState.isMinterAdmin
// If no pending transaction found, return null // If no pending transaction found, return null
if (!pendingApprovals || pendingApprovals.length === 0) { if (!pendingTxs || pendingTxs.length === 0) {
console.warn("no pending approval transactions found, returning null...") console.warn("no pending transactions found, returning null...")
return null return null
} }
const txSig = pendingApprovals[0].signature const txSig = pendingTxs[0].signature
// Find the relevant signature. (First approval) // Find the relevant signature. (signature of the issued transaction pending.)
const relevantApprovals = approvalSearchResults.filter( const relevantApprovals = approvalSearchResults.filter(
(approvalTx) => approvalTx.pendingSignature === txSig (approvalTx) => approvalTx.pendingSignature === txSig
) )
@ -2058,8 +2130,10 @@ const createCardHTML = async (cardData, pollResults, cardIdentifier, commentCoun
</button> </button>
`).join("") `).join("")
const minterGroupMembers = await fetchMinterGroupMembers() // const minterGroupMembers = await fetchMinterGroupMembers()
const minterAdmins = await fetchMinterGroupAdmins() const minterGroupMembers = cachedMinterGroup
// const minterAdmins = await fetchMinterGroupAdmins()
const minterAdmins = cachedMinterAdmins
const { adminYes = 0, adminNo = 0, minterYes = 0, minterNo = 0, totalYes = 0, totalNo = 0, totalYesWeight = 0, totalNoWeight = 0, detailsHtml, userVote } = await processPollData(pollResults, minterGroupMembers, minterAdmins, creator, cardIdentifier) const { adminYes = 0, adminNo = 0, minterYes = 0, minterNo = 0, totalYes = 0, totalNo = 0, totalYesWeight = 0, totalNoWeight = 0, detailsHtml, userVote } = await processPollData(pollResults, minterGroupMembers, minterAdmins, creator, cardIdentifier)
createModal('links') createModal('links')
createModal('poll-details') createModal('poll-details')

View File

@ -1,4 +1,4 @@
const Q_MINTERSHIP_VERSION = "1.06.4" const Q_MINTERSHIP_VERSION = "1.21"
const messageIdentifierPrefix = `mintership-forum-message` const messageIdentifierPrefix = `mintership-forum-message`
const messageAttachmentIdentifierPrefix = `mintership-forum-attachment` const messageAttachmentIdentifierPrefix = `mintership-forum-attachment`

View File

@ -6,6 +6,11 @@ let baseUrl = ''
let isOutsideOfUiDevelopment = false let isOutsideOfUiDevelopment = false
let nullAddress = 'QdSnUy6sUiEnaN87dWmE92g1uQjrvPgrWG' let nullAddress = 'QdSnUy6sUiEnaN87dWmE92g1uQjrvPgrWG'
// Caching to improve performance
const nameInfoCache = new Map(); // name -> nameInfo
const addressInfoCache = new Map(); // address -> addressInfo
const pollResultsCache = new Map(); // pollName -> pollResults
if (typeof qortalRequest === 'function') { if (typeof qortalRequest === 'function') {
console.log('qortalRequest is available as a function. Setting development mode to false and baseUrl to nothing.') console.log('qortalRequest is available as a function. Setting development mode to false and baseUrl to nothing.')
isOutsideOfUiDevelopment = false isOutsideOfUiDevelopment = false
@ -223,6 +228,13 @@ const getUserAddress = async () => {
} }
} }
const getAddressInfoCached = async (address) => {
if (addressInfoCache.has(address)) return addressInfoCache.get(address)
const result = await getAddressInfo(address)
addressInfoCache.set(address, result)
return result
}
const getAddressInfo = async (address) => { const getAddressInfo = async (address) => {
const qortalAddressPattern = /^Q[A-Za-z0-9]{33}$/ // Q + 33 almum = 34 total length const qortalAddressPattern = /^Q[A-Za-z0-9]{33}$/ // Q + 33 almum = 34 total length
@ -254,6 +266,19 @@ const getAddressInfo = async (address) => {
} }
} }
const nameToAddressCache = new Map()
const fetchOwnerAddressFromNameCached = async (name) => {
if (nameToAddressCache.has(name)) {
return nameToAddressCache.get(name)
}
const address = await fetchOwnerAddressFromName(name)
nameToAddressCache.set(name, address)
return address
}
const fetchOwnerAddressFromName = async (name) => { const fetchOwnerAddressFromName = async (name) => {
console.log('fetchOwnerAddressFromName called') console.log('fetchOwnerAddressFromName called')
console.log('name:', name) console.log('name:', name)
@ -332,6 +357,15 @@ const verifyAddressIsAdmin = async (address) => {
throw error throw error
} }
} }
const getNameInfoCached = async (name) => {
if (nameInfoCache.has(name)) {
return nameInfoCache.get(name)
}
const result = await getNameInfo(name)
nameInfoCache.set(name, result)
return result
}
const getNameInfo = async (name) => { const getNameInfo = async (name) => {
console.log('getNameInfo called') console.log('getNameInfo called')
@ -1239,6 +1273,20 @@ const getProductDetails = async (service, name, identifier) => {
// Qortal poll-related calls ---------------------------------------------------------------------- // Qortal poll-related calls ----------------------------------------------------------------------
const pollOwnerAddrCache = new Map()
const getPollOwnerAddressCached = async (pollName) => {
if (pollOwnerAddrCache.has(pollName)) {
return pollOwnerAddrCache.get(pollName)
}
const ownerAddress = await getPollOwnerAddress(pollName)
// Store in cache
pollOwnerAddrCache.set(pollName, ownerAddress)
return ownerAddress
}
const getPollOwnerAddress = async (pollName) => { const getPollOwnerAddress = async (pollName) => {
try { try {
const response = await fetch(`${baseUrl}/polls/${pollName}`, { const response = await fetch(`${baseUrl}/polls/${pollName}`, {
@ -1267,6 +1315,15 @@ const getPollPublisherPublicKey = async (pollName) => {
} }
} }
const fetchPollResultsCached = async (pollName) => {
if (pollResultsCache.has(pollName)) {
return pollResultsCache.get(pollName)
}
const result = await fetchPollResults(pollName)
pollResultsCache.set(pollName, result)
return result
}
const fetchPollResults = async (pollName) => { const fetchPollResults = async (pollName) => {
try { try {
const response = await fetch(`${baseUrl}/polls/votes/${pollName}`, { const response = await fetch(`${baseUrl}/polls/votes/${pollName}`, {
@ -1354,7 +1411,7 @@ const processTransaction = async (signedTransaction) => {
// Create a group invite transaction. This will utilize a default timeToLive (which is how long the tx will be alive, not the time until it IS live...) of 10 days in seconds, as the legacy UI has a bug that doesn't display invites older than 10 days. // Create a group invite transaction. This will utilize a default timeToLive (which is how long the tx will be alive, not the time until it IS live...) of 10 days in seconds, as the legacy UI has a bug that doesn't display invites older than 10 days.
// We will also default to the MINTER group for groupId, AFTER the GROUP_APPROVAL changes, the txGroupId will need to be set for tx that require approval. // We will also default to the MINTER group for groupId, AFTER the GROUP_APPROVAL changes, the txGroupId will need to be set for tx that require approval.
const createGroupInviteTransaction = async (recipientAddress, adminPublicKey, groupId=694, invitee, timeToLive, txGroupId, fee) => { const createGroupInviteTransaction = async (recipientAddress, adminPublicKey, groupId=694, invitee, timeToLive=0, txGroupId, fee) => {
try { try {
// Fetch account reference correctly // Fetch account reference correctly

View File

@ -196,13 +196,25 @@ const fetchAllInviteTransactions = async () => {
const { finalTx: finalInviteTxs, pendingTx: pendingInviteTxs } = partitionTransactions(allInviteTx) const { finalTx: finalInviteTxs, pendingTx: pendingInviteTxs } = partitionTransactions(allInviteTx)
console.log('Final kickTxs:', finalInviteTxs) console.log('Final InviteTxs:', finalInviteTxs)
console.log('Pending kickTxs:', pendingInviteTxs) console.log('Pending InviteTxs:', pendingInviteTxs)
return { return {
finalInviteTxs, finalInviteTxs,
pendingInviteTxs, pendingInviteTxs,
} }
} }
const findPendingApprovalsForTxSignature = async (txSignature, txType='GROUP_APPROVAL', limit=0, offset=0) => {
const pendingTxs = await searchPendingTransactions(limit, offset)
// Filter only the relevant GROUP_APPROVAL TX referencing txSignature
const approvals = pendingTxs.filter(tx =>
tx.type === txType && tx.pendingSignature === txSignature
)
console.log(`approvals found:`,approvals)
return approvals
}