// // 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 = false const minterCardIdentifierPrefix = "Minter-board-card" const minterBoardPublishEditorKey = "minter-card-content" let isExistingCard = false let existingCardData = {} let existingCardIdentifier = "" const MINTER_GROUP_ID = 694 const MIN_ADMIN_YES_VOTES = 9 const GROUP_APPROVAL_FEATURE_TRIGGER_HEIGHT = 2012800 //TODO update this to correct featureTrigger height when known, either that, or pull from core. let featureTriggerPassed = false let isApproved = false let cachedMinterAdmins let cachedMinterGroup let minterBoardPublishInProgress = false // Kakashi Note: Batch size tuned for progressive rendering so cards appear quickly without overloading QDN requests. const MINTER_SCROLL_BATCH_SIZE = 12 const minterBoardInfiniteState = { loadToken: 0, cards: [], cursor: 0, inFlight: false, complete: false, isARBoard: false, showExisting: false, displayedCount: 0, mintedCount: 0, totalCount: 0, isBackgroundLoading: false, counterSpan: null, container: null, scrollHandler: null, backgroundRunnerToken: 0, } const minterBoardSearchCacheByPrefix = new Map() const minterBoardCardDataCache = new Map() const minterBoardCardDataByIdentifier = new Map() const optimisticMinterBoardCardCache = new Map() const optimisticMinterBoardCommentCache = new Map() const minterAvatarMarkupCache = new Map() const MINTER_BOARD_UPDATE_CHECK_INTERVAL_MS = 60000 const MINTER_NOTIFICATION_SETTINGS_IDENTIFIER_PREFIX = "Mintership-notification-settings-v1" const MINTER_NOTIFICATION_STATE_IDENTIFIER_PREFIX = "Mintership-notification-state-v1" const MINTER_NOTIFICATION_EVENT_IDENTIFIER_PREFIX = "Mintership-notification-event-v1" const MINTER_NOTIFICATION_QMAIL_IDENTIFIER_PREFIX = "_mail_qortal_qmail_" const MINTER_NOTIFICATION_GROUP_NAME = "Q-Mintership-NOTIFICATIONS" const MINTER_NOTIFICATION_GROUP_ID = 1099 const MINTER_NOTIFICATION_SCHEMA_VERSION = 1 const minterBoardUpdateState = { timer: null, inFlight: false, cardSnapshot: new Map(), commentSnapshot: new Map(), pollSnapshot: new Map(), inviteSnapshot: new Map(), pending: null, } const minterBoardNotificationSettingsCache = { timestamp: 0, data: [], } const MINTER_NOTIFICATION_SETTINGS_CACHE_TTL_MS = 60000 const MINTER_NOTIFICATION_STATE_CACHE_TTL_MS = 60000 const DEFAULT_MINTER_NOTIFICATION_EVENTS = { comment: true, reply: true, admin_vote: true, minter_vote: true, user_vote: true, invite_created: true, group_approval: true, joined: true, } const normalizeMinterNotificationGroupId = (value) => { const parsed = Number(String(value ?? "").trim()) return Number.isInteger(parsed) && parsed > 0 ? parsed : null } const resolveMinterNotificationBroadcastGroupId = () => MINTER_NOTIFICATION_GROUP_ID const minterBoardNotificationDeliveryState = { batch: null, isPublishing: false, } const minterBoardNotificationStateCache = { timestamp: 0, data: [], } const minterBoardNotificationGroupMembershipState = { timestamp: 0, accountAddress: "", isMember: false, inFlight: false, } const MINTER_NOTIFICATION_GROUP_MEMBERSHIP_CACHE_TTL_MS = 60000 const getMinterBoardDisplayMode = () => { const displayModeSelect = document.getElementById("display-mode-select") return displayModeSelect?.value === "list" ? "list" : "cards" } const loadMinterBoardPage = async () => { // Kakashi Note: Remove existing board scroll listeners before loading this page to prevent duplicate lazy-load triggers. if (typeof detachAdminBoardInfiniteScroll === "function") { detachAdminBoardInfiniteScroll() } if (typeof detachMinterBoardInfiniteScroll === "function") { detachMinterBoardInfiniteScroll() } qMintershipActiveBoard = "minter" stopMinterBoardBackgroundUpdateChecks() clearQMintershipBodyContent() // Add the "Minter Board" content const mainContent = document.createElement("div") const publishButtonColor = "#527c9d" const minterBoardNameColor = "#527c9d" // Kakashi Note: Nomination flow captures nominee identity separately from the publishing minter. mainContent.innerHTML = `

The Minter Board

1

Your Nominator Creates a Minter Card for You that Goes up for Discussion + Vote. Note - ONE Minting Account Per Person.

2

Community + Minter Admins Comment & Vote. A GROUP_APPROVAL invite from Minter Admins to MINTER Group is Created if Successful.

3

Check Back Frequently and See the Current Status, and Accept Your Invite Upon Success.

DISPLAY SETTINGS

Choose how the board is sorted and filtered.

` document.body.appendChild(mainContent) if (typeof clearBoardCommentEditState === "function") { clearBoardCommentEditState() } if (typeof boardCommentContentCache !== "undefined") { boardCommentContentCache.clear() } if (typeof boardCommentDataCache !== "undefined") { boardCommentDataCache.clear() } createScrollToTopButton() document .getElementById("publish-card-button") .addEventListener("click", async () => { isExistingCard = false existingCardData = {} existingCardIdentifier = "" const publishForm = document.getElementById("publish-card-form") publishForm.reset() const linksContainer = document.getElementById("links-container") linksContainer.innerHTML = `` const publishCardView = document.getElementById("publish-card-view") publishCardView.style.display = "flex" document.getElementById("cards-container").style.display = "none" if (typeof ensureBoardRichTextEditor === "function") { ensureBoardRichTextEditor( minterBoardPublishEditorKey, "Share why this nominee should be considered for minting privileges." ) clearBoardRichTextEditor(minterBoardPublishEditorKey) } const submitButton = document.getElementById("submit-publish-button") if (submitButton) { submitButton.textContent = "PUBLISH" } }) document .getElementById("refresh-cards-button") .addEventListener("click", async () => { // Update the caches to include any new changes (e.g. new minters) await initializeCachedGroups() // Optionally show a "refreshing" message const cardsContainer = document.getElementById("cards-container") cardsContainer.innerHTML = getBoardLoadingHTML("Refreshing cards...") hideMinterBoardUpdateBanner() // Then reload the cards with the updated cache data await loadCards(minterCardIdentifierPrefix, true) }) document .getElementById("cancel-publish-button") .addEventListener("click", async () => { const publishForm = document.getElementById("publish-card-form") if (publishForm) { publishForm.reset() } if (typeof clearBoardRichTextEditor === "function") { clearBoardRichTextEditor(minterBoardPublishEditorKey) } const cardsContainer = document.getElementById("cards-container") cardsContainer.style.display = "flex" // Restore visibility const publishCardView = document.getElementById("publish-card-view") publishCardView.style.display = "none" // Hide the publish form isExistingCard = false existingCardData = {} existingCardIdentifier = "" const submitButton = document.getElementById("submit-publish-button") if (submitButton) { submitButton.textContent = "PUBLISH" } }) document .getElementById("add-link-button") .addEventListener("click", async () => { const linksContainer = document.getElementById("links-container") const newLinkInput = document.createElement("input") newLinkInput.type = "text" newLinkInput.className = "card-link" newLinkInput.placeholder = "Enter QDN link" linksContainer.appendChild(newLinkInput) }) document .getElementById("publish-card-form") .addEventListener("submit", async (event) => { event.preventDefault() await publishCard(minterCardIdentifierPrefix) }) document .getElementById("time-range-select") .addEventListener("change", async () => { // Re-load the cards whenever user chooses a new sort option. await loadCards(minterCardIdentifierPrefix) }) document .getElementById("sort-select") .addEventListener("change", async () => { // Re-load the cards whenever user chooses a new sort option. await loadCards(minterCardIdentifierPrefix) }) document .getElementById("display-mode-select") .addEventListener("change", async () => { await loadCards(minterCardIdentifierPrefix) }) const showExistingCardsCheckbox = document.getElementById( "show-existing-checkbox" ) if (showExistingCardsCheckbox) { showExistingCardsCheckbox.addEventListener("change", async (event) => { await loadCards(minterCardIdentifierPrefix) }) } document .getElementById("notification-settings-button") .addEventListener("click", async () => { await openMinterNotificationSettingsModal() }) document .getElementById("notification-review-button") .addEventListener("click", async () => { if (minterBoardNotificationDeliveryState.batch) { await openMinterNotificationDeliveryModal( minterBoardNotificationDeliveryState.batch ) } }) refreshMinterNotificationReviewButton() await refreshMinterNotificationGroupPrompt() //Initialize Minter Group and Admin Group await initializeCachedGroups() await featureTriggerCheck() 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 resolvedMinterNameByIdentifierCache = new Map() const getSingleSearchResource = (result) => { if (!result) return null return Array.isArray(result) ? result[0] || null : result } const extractMinterCardsMinterName = async (cardIdentifier) => { if (resolvedMinterNameByIdentifierCache.has(cardIdentifier)) { return resolvedMinterNameByIdentifierCache.get(cardIdentifier) } // Ensure the identifier starts with the prefix if ( !cardIdentifier.startsWith(minterCardIdentifierPrefix) && !cardIdentifier.startsWith(addRemoveIdentifierPrefix) ) { throw new Error("minterCard does not match identifier check") } // Split the identifier into parts const parts = cardIdentifier.split("-") // Ensure the format has at least 3 parts if (parts.length < 3) { throw new Error("Invalid identifier format") } try { if (cardIdentifier.startsWith(minterCardIdentifierPrefix)) { const searchSimpleResults = await searchSimple( "BLOG_POST", `${cardIdentifier}`, "", 1, 0, "", false, true ) const resource = getSingleSearchResource(searchSimpleResults) if (!resource || !resource.name) { throw new Error( `No publisher found for minter card identifier ${cardIdentifier}` ) } const publisherName = resource.name const cardDataResponse = await qortalRequest({ action: "FETCH_QDN_RESOURCE", name: publisherName, service: "BLOG_POST", identifier: cardIdentifier, }) // Kakashi Note: Dedupe identity follows the nominee, with publisher fallback for legacy cards. const nomineeName = getCardNomineeName(cardDataResponse) const resolvedName = nomineeName || publisherName resolvedMinterNameByIdentifierCache.set(cardIdentifier, resolvedName) return resolvedName } else if (cardIdentifier.startsWith(addRemoveIdentifierPrefix)) { const searchSimpleResults = await searchSimple( "BLOG_POST", `${cardIdentifier}`, "", 1, 0, "", false, true ) const resource = getSingleSearchResource(searchSimpleResults) if (!resource || !resource.name) { throw new Error( `No publisher found for AR card identifier ${cardIdentifier}` ) } const publisherName = resource.name const cardDataResponse = await qortalRequest({ action: "FETCH_QDN_RESOURCE", name: publisherName, service: "BLOG_POST", identifier: cardIdentifier, }) const minterName = cardDataResponse.minterName if (minterName) { resolvedMinterNameByIdentifierCache.set(cardIdentifier, minterName) return minterName } else { console.warn( `Identifier ${cardIdentifier} is missing minterName. Falling back to publisher name.` ) resolvedMinterNameByIdentifierCache.set(cardIdentifier, publisherName) return publisherName } } } catch (error) { throw error } } const groupAndLabelByIdentifier = (allCards) => { // Group by identifier const mapById = new Map() allCards.forEach((card) => { if (!mapById.has(card.identifier)) { mapById.set(card.identifier, []) } mapById.get(card.identifier).push(card) }) // For each identifier's group, sort oldest->newest so the first is "master" const output = [] for (const [identifier, group] of mapById.entries()) { group.sort((a, b) => { const aTime = a.created || 0 const bTime = b.created || 0 return aTime - bTime // oldest first }) // Mark the first as master group[0].isMaster = true // The rest are updates for (let i = 1; i < group.length; i++) { group[i].isMaster = false } // push them all to output output.push(...group) } return output } const groupByIdentifierOldestFirst = (allCards) => { // map of identifier => array of cards const mapById = new Map() allCards.forEach((card) => { if (!mapById.has(card.identifier)) { mapById.set(card.identifier, []) } mapById.get(card.identifier).push(card) }) // sort each group oldest->newest for (const [identifier, group] of mapById.entries()) { group.sort((a, b) => { const aTime = a.created || 0 const bTime = b.created || 0 return aTime - bTime // oldest first }) } return mapById } const buildMinterNameGroups = async (mapById) => { // We'll build an array of objects: { minterName, cards } // Then we can combine any that share the same minterName. const nameGroups = [] for (let [identifier, group] of mapById.entries()) { // group[0] is the oldest => "master" card let masterCard = group[0] // Filter out any cards that are not published by the 'masterPublisher' const masterPublisherName = masterCard.name // Remove any cards in this identifier group that have a different publisherName const filteredGroup = group.filter((c) => c.name === masterPublisherName) // If filtering left zero cards, skip entire group if (!filteredGroup.length) { console.warn( `All cards removed for identifier=${identifier} (different publishers). Skipping.` ) continue } // Reassign group to the filtered version, then re-define masterCard group = filteredGroup masterCard = group[0] // oldest after filtering // attempt to obtain minterName from the master card let masterMinterName try { masterMinterName = await extractMinterCardsMinterName( masterCard.identifier ) } catch (err) { console.warn( `Skipping entire group ${identifier}, no valid minterName from master`, err ) continue } // Store an object with the minterName we extracted, plus all cards in that group nameGroups.push({ minterName: masterMinterName, cards: group, // includes the master & updates }) } // Combine them: minterName => array of *all* cards from all matching groups const combinedMap = new Map() for (const entry of nameGroups) { const mName = entry.minterName if (!combinedMap.has(mName)) { combinedMap.set(mName, []) } combinedMap.get(mName).push(...entry.cards) } return combinedMap } const getNewestCardPerMinterName = (combinedMap) => { // We'll produce an array of the newest card for each minterName, this will be utilized as the 'final filter' to display cards published/updated by unique minters. const finalOutput = [] for (const [mName, cardArray] of combinedMap.entries()) { // sort by updated or created, descending => newest first cardArray.sort((a, b) => { const aTime = a.updated || a.created || 0 const bTime = b.updated || b.created || 0 return bTime - aTime }) // newest is [0] finalOutput.push(cardArray[0]) } // Then maybe globally sort them newest first finalOutput.sort((a, b) => { const aTime = a.updated || a.created || 0 const bTime = b.updated || b.created || 0 return bTime - aTime }) return finalOutput } const processMinterBoardCards = async (allValidCards) => { // group by identifier, sorted oldest->newest const mapById = groupByIdentifierOldestFirst(allValidCards) // build a map of minterName => all cards from those identifiers const minterNameMap = await buildMinterNameGroups(mapById) // from that map, keep only the single newest card per minterName const newestCards = getNewestCardPerMinterName(minterNameMap) // return final array of all newest cards return newestCards } const processARBoardCards = async (allValidCards) => { const mapById = groupByIdentifierOldestFirst(allValidCards) // build a map of minterName => all cards from those identifiers const mapByName = await buildMinterNameGroups(mapById) // For each minterName group, we might want to sort them newest->oldest const finalOutput = [] for (const [minterName, group] of mapByName.entries()) { group.sort((a, b) => { const aTime = a.updated || a.created || 0 const bTime = b.updated || b.created || 0 return bTime - aTime }) // Both resolution for the duplicate QuickMythril card, and handling of all future duplicates that may be published... if (group[0].identifier === "QM-AR-card-Xw3dxL") { console.warn( `This is a bug that allowed a duplicate prior to the logic displaying them based on original publisher only... displaying in reverse order...` ) group[0].isDuplicate = true for (let i = 1; i < group.length; i++) { group[i].isDuplicate = false } } else { group[0].isDuplicate = false for (let i = 1; i < group.length; i++) { group[i].isDuplicate = true } } // push them all finalOutput.push(...group) } // Sort final by newest overall finalOutput.sort((a, b) => { const aTime = a.updated || a.created || 0 const bTime = b.updated || b.created || 0 return bTime - aTime }) return finalOutput } const getCardNomineeName = (cardData = {}, fallback = "") => cardData?.nominee || cardData?.creator || fallback const getCardNomineeAddress = (cardData = {}, fallback = "") => cardData?.nomineeAddress || cardData?.creatorAddress || fallback const getCardNominatorName = (cardData = {}, fallback = "") => cardData?.nominator || cardData?.publishedBy || fallback const getCardNominatorAddress = (cardData = {}, fallback = "") => cardData?.nominatorAddress || cardData?.publishedByAddress || fallback const resolveCardNomineeAddress = async (cardResource, cardData) => { // Kakashi Note: Prefer the published nominee address for level and invite checks; fallback paths keep legacy payloads compatible. const nomineeAddress = getCardNomineeAddress(cardData) if (nomineeAddress) { return nomineeAddress } const nomineeName = getCardNomineeName(cardData) if (nomineeName) { const ownerFromNominee = await fetchOwnerAddressFromNameCached(nomineeName) if (ownerFromNominee) { return ownerFromNominee } } return await fetchOwnerAddressFromNameCached(cardResource.name) } const getBoardResourceTimestamp = (resource) => resource?.updated || resource?.created || 0 const getBoardResourceCacheKey = (resource) => `${resource?.name || ""}::${ resource?.identifier || "" }::${getBoardResourceTimestamp(resource)}` const getBoardResourceIdentityKey = (resource) => `${resource?.name || ""}::${resource?.identifier || ""}` const getOptimisticMinterBoardCardCacheKey = (publisherName, cardIdentifier) => `${publisherName || ""}::${cardIdentifier || ""}` const getOptimisticMinterBoardCommentCacheKey = ( publisherName, commentIdentifier ) => `${publisherName || ""}::${commentIdentifier || ""}` const rememberOptimisticMinterBoardCard = ( cardIdentifierPrefix, publisherName, cardIdentifier, cardData, timestamp = Date.now() ) => { if (!cardIdentifierPrefix || !publisherName || !cardIdentifier || !cardData) return const resource = { name: publisherName, service: "BLOG_POST", identifier: cardIdentifier, created: timestamp, updated: timestamp, _optimisticCard: true, _cardIdentifierPrefix: cardIdentifierPrefix, } const cacheKey = getOptimisticMinterBoardCardCacheKey( publisherName, cardIdentifier ) optimisticMinterBoardCardCache.set(cacheKey, { cardIdentifierPrefix, resource, cardData: { ...cardData, _optimisticPending: true, }, }) resolvedMinterNameByIdentifierCache.set( cardIdentifier, getCardNomineeName(cardData, publisherName) ) } const getOptimisticMinterBoardResources = ( cardIdentifierPrefix, afterTime = 0, existingResourcesByIdentity = new Map() ) => { const resources = [] for (const [cacheKey, entry] of optimisticMinterBoardCardCache.entries()) { if ( !entry || entry.cardIdentifierPrefix !== cardIdentifierPrefix || !entry.resource ) continue const resourceTimestamp = getBoardResourceTimestamp(entry.resource) if (afterTime > 0 && resourceTimestamp < afterTime) continue const identityKey = getBoardResourceIdentityKey(entry.resource) const existingResource = existingResourcesByIdentity.get(identityKey) const existingTimestamp = getBoardResourceTimestamp(existingResource) if (existingResource && existingTimestamp >= resourceTimestamp) { optimisticMinterBoardCardCache.delete(cacheKey) continue } resources.push(entry.resource) } return resources } const getMinterBoardSearchCacheEntry = (cardIdentifierPrefix) => { if (!minterBoardSearchCacheByPrefix.has(cardIdentifierPrefix)) { minterBoardSearchCacheByPrefix.set(cardIdentifierPrefix, { resourcesByKey: new Map(), maxDaysCovered: 0, hasAllRange: false, }) } return minterBoardSearchCacheByPrefix.get(cardIdentifierPrefix) } const fetchCachedBoardSearchResources = async ( cardIdentifierPrefix, dayRange, afterTime, forceSearch = false ) => { const cacheEntry = getMinterBoardSearchCacheEntry(cardIdentifierPrefix) if (forceSearch) { cacheEntry.resourcesByKey.clear() cacheEntry.maxDaysCovered = 0 cacheEntry.hasAllRange = false } const cacheCoversRange = dayRange === 0 ? cacheEntry.hasAllRange : cacheEntry.hasAllRange || cacheEntry.maxDaysCovered >= dayRange if (!cacheCoversRange) { const fetched = await searchSimple( "BLOG_POST", cardIdentifierPrefix, "", 0, 0, "", false, true, afterTime ) const fetchedArray = Array.isArray(fetched) ? fetched : [] for (const resource of fetchedArray) { cacheEntry.resourcesByKey.set( getBoardResourceCacheKey(resource), resource ) } if (dayRange === 0) { cacheEntry.hasAllRange = true } else { cacheEntry.maxDaysCovered = Math.max(cacheEntry.maxDaysCovered, dayRange) } } const allCached = Array.from(cacheEntry.resourcesByKey.values()) const existingResourcesByIdentity = new Map( allCached.map((resource) => [ getBoardResourceIdentityKey(resource), resource, ]) ) const optimisticResources = getOptimisticMinterBoardResources( cardIdentifierPrefix, afterTime, existingResourcesByIdentity ) const mergedCached = [...optimisticResources, ...allCached] if (afterTime > 0) { return mergedCached.filter( (resource) => getBoardResourceTimestamp(resource) >= afterTime ) } return mergedCached } const fetchMinterBoardCardDataCached = async (cardResource) => { const optimisticEntry = optimisticMinterBoardCardCache.get( getOptimisticMinterBoardCardCacheKey( cardResource?.name, cardResource?.identifier ) ) if (optimisticEntry?.cardData) { return optimisticEntry.cardData } const cacheKey = getBoardResourceCacheKey(cardResource) if (minterBoardCardDataCache.has(cacheKey)) { return minterBoardCardDataCache.get(cacheKey) } const data = await qortalRequest({ action: "FETCH_QDN_RESOURCE", name: cardResource.name, service: "BLOG_POST", identifier: cardResource.identifier, }) minterBoardCardDataCache.set(cacheKey, data) return data } const rememberOptimisticMinterBoardComment = ( cardIdentifier, publisherName, commentIdentifier, commentData, timestamp = Date.now() ) => { if (!cardIdentifier || !publisherName || !commentIdentifier || !commentData) return const resource = { name: publisherName, service: "BLOG_POST", identifier: commentIdentifier, created: timestamp, updated: timestamp, _optimisticComment: true, _cardIdentifier: cardIdentifier, } optimisticMinterBoardCommentCache.set( getOptimisticMinterBoardCommentCacheKey(publisherName, commentIdentifier), { cardIdentifier, resource, commentData: { ...commentData, _optimisticPending: true, }, } ) if (typeof rememberBoardCommentContent === "function") { rememberBoardCommentContent(commentIdentifier, commentData?.content || "") } if (typeof rememberBoardCommentData === "function") { rememberBoardCommentData(commentIdentifier, commentData) } } const getOptimisticMinterBoardComments = ( cardIdentifier, existingResourcesByIdentity = new Map() ) => { const comments = [] for (const [cacheKey, entry] of optimisticMinterBoardCommentCache.entries()) { if (!entry || entry.cardIdentifier !== cardIdentifier || !entry.resource) continue const identityKey = getBoardResourceIdentityKey(entry.resource) const existingResource = existingResourcesByIdentity.get(identityKey) const existingTimestamp = getBoardResourceTimestamp(existingResource) const optimisticTimestamp = getBoardResourceTimestamp(entry.resource) if (existingResource && existingTimestamp >= optimisticTimestamp) { optimisticMinterBoardCommentCache.delete(cacheKey) continue } comments.push(entry.resource) } return comments } const fetchMinterBoardCommentData = async (commentResource) => { const optimisticEntry = optimisticMinterBoardCommentCache.get( getOptimisticMinterBoardCommentCacheKey( commentResource?.name, commentResource?.identifier ) ) if (optimisticEntry?.commentData) { return optimisticEntry.commentData } return await qortalRequest({ action: "FETCH_QDN_RESOURCE", name: commentResource.name, service: "BLOG_POST", identifier: commentResource.identifier, }) } const detachMinterBoardInfiniteScroll = () => { if (minterBoardInfiniteState.scrollHandler) { window.removeEventListener("scroll", minterBoardInfiniteState.scrollHandler) minterBoardInfiniteState.scrollHandler = null } stopMinterBoardBackgroundUpdateChecks() } const getMinterBoardUpdateResourceSignature = (resources = []) => { const safeResources = Array.isArray(resources) ? resources : [] const newestTimestamp = safeResources.reduce((newest, resource) => { const timestamp = getBoardResourceTimestamp(resource) return timestamp > newest ? timestamp : newest }, 0) return { count: safeResources.length, newestTimestamp, } } const setMinterBoardCardSnapshot = (resources = []) => { minterBoardUpdateState.cardSnapshot = new Map( (Array.isArray(resources) ? resources : []).map((resource) => [ getBoardResourceIdentityKey(resource), getBoardResourceTimestamp(resource), ]) ) } const rememberMinterBoardCommentSnapshot = (cardIdentifier, resources = []) => { const normalizedIdentifier = String(cardIdentifier || "").trim() if (!normalizedIdentifier) return minterBoardUpdateState.commentSnapshot.set( normalizedIdentifier, getMinterBoardUpdateResourceSignature(resources) ) } const getMinterBoardPollSignature = (pollResults = null) => { if (!pollResults || !Array.isArray(pollResults.votes)) { return { voteCount: 0, voteWeightKey: "" } } const voteWeightKey = Array.isArray(pollResults.voteWeights) ? pollResults.voteWeights .map( (weight) => `${weight?.optionName || ""}:${weight?.voteWeight || 0}` ) .join("|") : "" return { voteCount: pollResults.votes.length, voteWeightKey, } } const rememberMinterBoardPollSnapshot = (pollName, pollResults = null) => { const normalizedPollName = String(pollName || "").trim() if (!normalizedPollName) return minterBoardUpdateState.pollSnapshot.set( normalizedPollName, getMinterBoardPollSignature(pollResults) ) } const getMinterBoardInviteSignature = (inviteState = {}) => { const displayStatus = getMinterBoardInviteDisplayStatus(inviteState) const hasApprovedInvite = Boolean(inviteState?.hasApprovedInvite) const hasPendingInvite = Boolean(inviteState?.hasPendingInvite) const hasGroupApproval = Boolean(inviteState?.hasGroupApproval) const isExistingMinter = Boolean(inviteState?.isExistingMinter) return `${displayStatus || "none"}:${hasApprovedInvite ? 1 : 0}:${ hasPendingInvite ? 1 : 0 }:${hasGroupApproval ? 1 : 0}:${isExistingMinter ? 1 : 0}` } const rememberMinterBoardInviteSnapshot = ( cardIdentifier, inviteState = {} ) => { const normalizedIdentifier = String(cardIdentifier || "").trim() if (!normalizedIdentifier) return minterBoardUpdateState.inviteSnapshot.set( normalizedIdentifier, getMinterBoardInviteSignature(inviteState) ) } const getMinterBoardInviteDisplayStatus = (inviteState = {}) => { if (inviteState?.isExistingMinter) { return "existing" } if (inviteState?.hasBanned) { return "banned" } if (inviteState?.hasKicked) { return "kicked" } if (inviteState?.hasApprovedInvite && !inviteState?.hasPendingInvite) { return "invited" } if (inviteState?.hasPendingInvite) { return "pending" } return "" } const hideMinterBoardUpdateBanner = () => { minterBoardUpdateState.pending = null const banner = document.getElementById("board-update-banner") if (!banner) return banner.hidden = true banner.innerHTML = "" } const showMinterBoardUpdateBanner = (summary = {}) => { const banner = document.getElementById("board-update-banner") if (!banner) return const newCards = Number(summary.cards || 0) const updatedCards = Number(summary.updatedCards || 0) const commentCards = Number(summary.commentCards || 0) const dataTypes = [] if (newCards > 0) { dataTypes.push(`${newCards} new nomination${newCards === 1 ? "" : "s"}`) } if (updatedCards > 0) { dataTypes.push( `${updatedCards} updated nomination${updatedCards === 1 ? "" : "s"}` ) } if (commentCards > 0) { dataTypes.push( `new comments on ${commentCards} card${commentCards === 1 ? "" : "s"}` ) } const pollCards = Number(summary.pollCards || 0) if (pollCards > 0) { dataTypes.push( `vote updates on ${pollCards} card${pollCards === 1 ? "" : "s"}` ) } const inviteCards = Number(summary.inviteCards || 0) if (inviteCards > 0) { dataTypes.push( `invite status updates on ${inviteCards} card${ inviteCards === 1 ? "" : "s" }` ) } const dataLabel = dataTypes.length ? dataTypes.join(", ") : "new board data" minterBoardUpdateState.pending = summary banner.innerHTML = `
New data found ${qEscapeHtml( dataLabel )} found. Load new data to update this board.
` banner.hidden = false } const fetchMinterBoardLiveCommentResources = async (cardIdentifier) => { const response = await searchSimple( "BLOG_POST", `comment-${cardIdentifier}`, "", 0, 0, "", "false" ) return Array.isArray(response) ? response : [] } const checkMinterBoardForUpdates = async () => { if (minterBoardUpdateState.inFlight) return const cardsContainer = document.getElementById("cards-container") if (!cardsContainer || !document.body.contains(cardsContainer)) { stopMinterBoardBackgroundUpdateChecks() return } minterBoardUpdateState.inFlight = true try { let afterTime = 0 const timeRangeSelect = document.getElementById("time-range-select") const days = parseInt(timeRangeSelect?.value || "0", 10) if (!Number.isNaN(days) && days > 0) { afterTime = Date.now() - days * 24 * 60 * 60 * 1000 } const liveCardResults = await searchSimple( "BLOG_POST", minterCardIdentifierPrefix, "", 0, 0, "", false, true, afterTime ) const liveCardCandidates = Array.isArray(liveCardResults) ? liveCardResults : [] const liveCards = ( await Promise.all( liveCardCandidates.map(async (resource) => (await validateCardStructure(resource)) ? resource : null ) ) ).filter(Boolean) const currentCardSnapshot = minterBoardUpdateState.cardSnapshot let newCards = 0 let updatedCards = 0 liveCards.forEach((resource) => { const identityKey = getBoardResourceIdentityKey(resource) const timestamp = getBoardResourceTimestamp(resource) if (!currentCardSnapshot.has(identityKey)) { newCards += 1 } else if (timestamp > (currentCardSnapshot.get(identityKey) || 0)) { updatedCards += 1 } }) const knownCardIdentifiers = Array.from( new Set( minterBoardInfiniteState.cards .map((card) => String(card?.identifier || "").trim()) .filter(Boolean) ) ) let commentCards = 0 const commentTasks = knownCardIdentifiers.map((cardIdentifier) => { return async () => { const liveComments = await fetchMinterBoardLiveCommentResources( cardIdentifier ) const liveSignature = getMinterBoardUpdateResourceSignature(liveComments) const previousSignature = minterBoardUpdateState.commentSnapshot.get(cardIdentifier) if ( previousSignature && (liveSignature.count > previousSignature.count || liveSignature.newestTimestamp > previousSignature.newestTimestamp) ) { commentCards += 1 } } }) await runWithConcurrency(commentTasks, 4) const knownPollNames = Array.from( new Set( minterBoardInfiniteState.cards .map((card) => { const cardData = minterBoardCardDataByIdentifier.get( card.identifier ) return String(cardData?.poll || "").trim() }) .filter(Boolean) ) ) let pollCards = 0 const pollTasks = knownPollNames.map((pollName) => { return async () => { if (typeof fetchPollResults !== "function") return const livePollResults = await fetchPollResults(pollName) const liveSignature = getMinterBoardPollSignature(livePollResults) const previousSignature = minterBoardUpdateState.pollSnapshot.get(pollName) if ( previousSignature && (liveSignature.voteCount > previousSignature.voteCount || liveSignature.voteWeightKey !== previousSignature.voteWeightKey) ) { pollCards += 1 } } }) await runWithConcurrency(pollTasks, 4) const knownInviteCards = Array.from( new Set( minterBoardInfiniteState.cards .map((card) => String(card?.identifier || "").trim()) .filter(Boolean) .filter((cardIdentifier) => document.body.contains( document.getElementById(`card-shell-${cardIdentifier}`) ) ) .filter((cardIdentifier) => { const cardData = minterBoardCardDataByIdentifier.get(cardIdentifier) || {} return cardData._inviteEligible === true }) ) ).map((cardIdentifier) => { const cardResource = minterBoardInfiniteState.cards.find( (card) => String(card?.identifier || "").trim() === cardIdentifier ) const cardData = minterBoardCardDataByIdentifier.get(cardIdentifier) || {} return { cardIdentifier, cardResource, cardData, nomineeAddress: String( getCardNomineeAddress(cardData, cardResource?.name || "") || cardData?.nomineeAddress || "" ).trim(), nomineeName: String( getCardNomineeName(cardData, cardResource?.name || "") || cardData?.nominee || cardResource?.name || "" ).trim(), } }) let inviteCards = 0 const changedInviteCards = [] const inviteTasks = knownInviteCards.map( ({ cardIdentifier, nomineeAddress, nomineeName }) => { return async () => { const liveInviteState = await resolveMinterBoardListTimelineState( nomineeAddress, nomineeName, false ) const nextSignature = getMinterBoardInviteSignature(liveInviteState) const previousSignature = minterBoardUpdateState.inviteSnapshot.get(cardIdentifier) if (previousSignature && previousSignature !== nextSignature) { inviteCards += 1 changedInviteCards.push(cardIdentifier) } minterBoardUpdateState.inviteSnapshot.set( cardIdentifier, nextSignature ) } } ) await runWithConcurrency(inviteTasks, 4) if (changedInviteCards.length > 0) { await runWithConcurrency( changedInviteCards.map((cardIdentifier) => { return async () => { const root = document.getElementById(`card-shell-${cardIdentifier}`) if (!root || !document.body.contains(root)) { return } const cardResource = minterBoardInfiniteState.cards.find( (card) => String(card?.identifier || "").trim() === cardIdentifier ) const cardData = minterBoardCardDataByIdentifier.get(cardIdentifier) if (!cardResource || !cardData) { return } const nomineeAddressValue = String( cardData?.nomineeAddress || getCardNomineeAddress(cardData, cardResource?.name || "") || "" ).trim() const isExistingMinter = Array.isArray(cachedMinterGroup) ? cachedMinterGroup.some( (member) => String(member?.member || "").trim() === nomineeAddressValue ) : false await hydrateMinterBoardCardDisplay({ cardResource, cardData, cardIdentifier, isExistingMinter, loadToken: minterBoardInfiniteState.loadToken, forceTimelineRefresh: true, }) } }), 2 ) } if ( newCards > 0 || updatedCards > 0 || commentCards > 0 || pollCards > 0 || inviteCards > 0 ) { showMinterBoardUpdateBanner({ cards: newCards, updatedCards, commentCards, pollCards, inviteCards, }) } } catch (error) { console.warn("Minter board background update check failed:", error) } finally { minterBoardUpdateState.inFlight = false } } const startMinterBoardBackgroundUpdateChecks = () => { stopMinterBoardBackgroundUpdateChecks() minterBoardUpdateState.timer = window.setInterval( checkMinterBoardForUpdates, MINTER_BOARD_UPDATE_CHECK_INTERVAL_MS ) } const stopMinterBoardBackgroundUpdateChecks = () => { if (minterBoardUpdateState.timer) { window.clearInterval(minterBoardUpdateState.timer) minterBoardUpdateState.timer = null } minterBoardUpdateState.inFlight = false } const loadMinterBoardDetectedUpdates = async () => { hideMinterBoardUpdateBanner() clearMinterBoardInviteStateCaches() await initializeCachedGroups() await loadCards(minterCardIdentifierPrefix, true) } const normalizeMinterNotificationSettings = (settings = {}) => { const cards = settings && typeof settings.cards === "object" && settings.cards !== null ? settings.cards : {} return { version: MINTER_NOTIFICATION_SCHEMA_VERSION, type: "minter-board-notification-settings", app: "Q-Mintership", publisher: settings.publisher || userState.accountName || "", publisherAddress: settings.publisherAddress || userState.accountAddress || "", updated: Number(settings.updated || Date.now()), global: { enabled: settings.global?.enabled !== false, qchat: settings.global?.qchat !== false, qmail: settings.global?.qmail === true, notificationGroupId: MINTER_NOTIFICATION_GROUP_ID, events: { ...DEFAULT_MINTER_NOTIFICATION_EVENTS, ...(settings.global?.events || {}), }, }, cards, } } const getCurrentUserNotificationSettingsIdentifier = () => `${MINTER_NOTIFICATION_SETTINGS_IDENTIFIER_PREFIX}-${ userState.accountName || "unknown" }` const fetchMinterBoardNotificationSettings = async (force = false) => { const now = Date.now() if ( !force && minterBoardNotificationSettingsCache.data.length && now - minterBoardNotificationSettingsCache.timestamp < MINTER_NOTIFICATION_SETTINGS_CACHE_TTL_MS ) { return minterBoardNotificationSettingsCache.data } try { const response = await searchSimple( "BLOG_POST", MINTER_NOTIFICATION_SETTINGS_IDENTIFIER_PREFIX, "", 0, 0, "", false, true ) const resources = Array.isArray(response) ? response : [] const tasks = resources.map((resource) => { return async () => { try { const data = await qortalRequest({ action: "FETCH_QDN_RESOURCE", name: resource.name, service: "BLOG_POST", identifier: resource.identifier, }) if (data?.type !== "minter-board-notification-settings") { return null } return normalizeMinterNotificationSettings({ ...data, publisher: data.publisher || resource.name, }) } catch (error) { console.warn("Unable to load notification settings:", error) return null } } }) const settings = (await runWithConcurrency(tasks, 6)).filter(Boolean) minterBoardNotificationSettingsCache.timestamp = now minterBoardNotificationSettingsCache.data = settings return settings } catch (error) { console.warn("Unable to search notification settings:", error) return minterBoardNotificationSettingsCache.data || [] } } const getCurrentUserMinterNotificationSettings = async (force = false) => { const allSettings = await fetchMinterBoardNotificationSettings(force) const currentName = String(userState.accountName || "").toLowerCase() const currentAddress = String(userState.accountAddress || "").toLowerCase() const existing = allSettings.find((settings) => { return ( String(settings.publisher || "").toLowerCase() === currentName || String(settings.publisherAddress || "").toLowerCase() === currentAddress ) }) return normalizeMinterNotificationSettings(existing || {}) } const publishCurrentUserMinterNotificationSettings = async (settings) => { if (!userState.accountName) { alert("A registered name is required to publish notification settings.") return null } const normalizedSettings = normalizeMinterNotificationSettings({ ...settings, publisher: userState.accountName, publisherAddress: userState.accountAddress || "", updated: Date.now(), }) let data64 = await objectToBase64(normalizedSettings) if (!data64) { data64 = btoa(JSON.stringify(normalizedSettings)) } await qortalRequest({ action: "PUBLISH_QDN_RESOURCE", name: userState.accountName, service: "BLOG_POST", identifier: getCurrentUserNotificationSettingsIdentifier(), data64, }) minterBoardNotificationSettingsCache.timestamp = 0 await fetchMinterBoardNotificationSettings(true) return normalizedSettings } const getCardNotificationPreference = (settings, cardIdentifier) => { const cardPreference = settings?.cards?.[cardIdentifier] if (!cardPreference) { return null } return { enabled: cardPreference.enabled !== false, qchat: cardPreference.channels?.qchat ?? settings.global?.qchat ?? true, qmail: cardPreference.channels?.qmail ?? settings.global?.qmail === true, events: { ...DEFAULT_MINTER_NOTIFICATION_EVENTS, ...(settings.global?.events || {}), ...(cardPreference.events || {}), }, } } const getMinterNotificationChannels = (settings, cardIdentifier) => { const cardChannels = settings?.cards?.[cardIdentifier]?.channels || {} return { qchat: cardChannels.qchat ?? settings?.global?.qchat !== false, qmail: cardChannels.qmail ?? settings?.global?.qmail === true, } } const getCurrentMinterNotificationStateIdentifier = () => MINTER_NOTIFICATION_STATE_IDENTIFIER_PREFIX const normalizeMinterNotificationState = (state = {}) => { const publishedActions = state && typeof state.publishedActions === "object" && state.publishedActions ? state.publishedActions : {} const cards = state && typeof state.cards === "object" && state.cards ? state.cards : {} return { version: MINTER_NOTIFICATION_SCHEMA_VERSION, type: "minter-board-notification-state", app: "Q-Mintership", publisher: state.publisher || userState.accountName || "", publisherAddress: state.publisherAddress || userState.accountAddress || "", updated: Number(state.updated || Date.now()), notificationGroupId: MINTER_NOTIFICATION_GROUP_ID, publishedActions, cards, summary: { totalActions: Number( state.summary?.totalActions || Object.keys(publishedActions).length ), totalCards: Number( state.summary?.totalCards || Object.keys(cards).length ), }, } } const fetchMinterBoardNotificationState = async (force = false) => { const now = Date.now() if ( !force && minterBoardNotificationStateCache.data.length && now - minterBoardNotificationStateCache.timestamp < MINTER_NOTIFICATION_STATE_CACHE_TTL_MS ) { return minterBoardNotificationStateCache.data[0] } try { const response = await searchSimple( "BLOG_POST", MINTER_NOTIFICATION_STATE_IDENTIFIER_PREFIX, "", 0, 0, "", false, true ) const resources = Array.isArray(response) ? response : [] const tasks = resources.map((resource) => { return async () => { try { const data = await qortalRequest({ action: "FETCH_QDN_RESOURCE", name: resource.name, service: "BLOG_POST", identifier: resource.identifier, }) if (data?.type !== "minter-board-notification-state") { return null } return normalizeMinterNotificationState({ ...data, publisher: data.publisher || resource.name, }) } catch (error) { console.warn("Unable to load notification state:", error) return null } } }) const states = (await runWithConcurrency(tasks, 6)) .filter(Boolean) .sort((a, b) => Number(b.updated || 0) - Number(a.updated || 0)) const latest = states[0] || normalizeMinterNotificationState({}) minterBoardNotificationStateCache.timestamp = now minterBoardNotificationStateCache.data = states.length ? states : [latest] return latest } catch (error) { console.warn("Unable to search notification state:", error) return ( minterBoardNotificationStateCache.data[0] || normalizeMinterNotificationState({}) ) } } const getMinterNotificationRecipientKey = (recipient = {}) => String(recipient.address || recipient.name || "") .trim() .toLowerCase() const mergeMinterNotificationRecipients = ( existingRecipients = [], nextRecipients = [] ) => { const recipientMap = new Map() ;[ ...(Array.isArray(existingRecipients) ? existingRecipients : []), ...(Array.isArray(nextRecipients) ? nextRecipients : []), ].forEach((recipient) => { const key = getMinterNotificationRecipientKey(recipient) if (!key) return const previous = recipientMap.get(key) || {} recipientMap.set(key, { ...previous, ...recipient, sources: Array.from( new Set([...(previous.sources || []), ...(recipient.sources || [])]) ), }) }) return Array.from(recipientMap.values()) } const buildMinterNotificationActionIdentifier = (event = {}) => { const eventType = String(event.eventType || "").trim() if (eventType === "comment" || eventType === "reply") { return ( String(event.actionIdentifier || event.commentIdentifier || "").trim() || `${event.cardIdentifier || "card"}:${eventType}:${ event.actorAddress || userState.accountAddress || "unknown" }` ) } if ( eventType === "admin_vote" || eventType === "minter_vote" || eventType === "user_vote" ) { return ( String(event.actionIdentifier || "").trim() || `${event.cardIdentifier || "card"}:${eventType}:${event.vote || "vote"}:${ event.actorAddress || userState.accountAddress || "unknown" }:${String(event.poll || "").slice(-32)}` ) } if (eventType === "invite_created") { return ( String(event.actionIdentifier || "").trim() || String( event.transaction?.signature || event.transaction?.sig || "" ).trim() || `${event.cardIdentifier || "card"}:${eventType}:${ event.nomineeName || "nominee" }` ) } if (eventType === "group_approval") { return ( String(event.actionIdentifier || event.pendingSignature || "").trim() || `${event.cardIdentifier || "card"}:${eventType}:${ event.transactionType || "approval" }` ) } if (eventType === "joined") { return ( String(event.actionIdentifier || "").trim() || `${event.cardIdentifier || "card"}:${eventType}:${ event.actorAddress || userState.accountAddress || "unknown" }` ) } return ( String(event.actionIdentifier || "").trim() || `${event.cardIdentifier || "card"}:${eventType}:${ event.actorAddress || userState.accountAddress || "unknown" }:${event.created || Date.now()}` ) } const buildMinterNotificationActionKey = (event = {}) => { const cardIdentifier = String(event.cardIdentifier || "card").trim() const eventType = String(event.eventType || "update").trim() const actionIdentifier = buildMinterNotificationActionIdentifier(event) return `${cardIdentifier}|${eventType}|${actionIdentifier}` } const buildMinterNotificationEventIdentifier = async (event = {}) => { const eventType = String(event.eventType || "update") .trim() .replace(/[^a-zA-Z0-9_-]/g, "_") .slice(0, 16) || "update" return `${MINTER_NOTIFICATION_EVENT_IDENTIFIER_PREFIX}-${eventType}-${await uid()}` } const buildMinterNotificationStateRecord = ( event = {}, recipients = [], notificationGroupId = MINTER_NOTIFICATION_GROUP_ID, delivery = {} ) => { const actionKey = buildMinterNotificationActionKey(event) const qchatRecipients = mergeMinterNotificationRecipients( [], (Array.isArray(recipients) ? recipients : []).filter( (recipient) => recipient.channels?.qchat ) ) const qmailRecipients = mergeMinterNotificationRecipients( [], (Array.isArray(recipients) ? recipients : []).filter( (recipient) => recipient.channels?.qmail ) ) const broadcastGroupId = notificationGroupId === undefined ? MINTER_NOTIFICATION_GROUP_ID : normalizeMinterNotificationGroupId(notificationGroupId) const announcementGroupId = normalizeMinterNotificationGroupId( delivery.announcementGroupId ?? broadcastGroupId ?? MINTER_NOTIFICATION_GROUP_ID ) return { actionKey, eventId: event.eventId || "", eventType: event.eventType || "", actionIdentifier: buildMinterNotificationActionIdentifier(event), cardIdentifier: event.cardIdentifier || "", nomineeName: event.nomineeName || "", nominatorName: event.nominatorName || "", summary: event.summary || "", publishedAt: Date.now(), publishedBy: userState.accountName || "", publishedByAddress: userState.accountAddress || "", notificationGroupId: broadcastGroupId, channels: { qchat: { published: qchatRecipients.length > 0 || Boolean(delivery.qchatBroadcastSent), recipientCount: qchatRecipients.length, recipients: qchatRecipients, broadcastGroupId, }, qmail: { published: qmailRecipients.length > 0, recipientCount: qmailRecipients.length, recipients: qmailRecipients, }, announcement: { published: Boolean(delivery.announcementPublished), groupId: announcementGroupId, identifier: delivery.announcementIdentifier || "", }, }, } } const mergeMinterNotificationStateRecord = ( existingRecord = {}, nextRecord = {} ) => { const mergedQchatRecipients = mergeMinterNotificationRecipients( existingRecord.channels?.qchat?.recipients || [], nextRecord.channels?.qchat?.recipients || [] ) const mergedQmailRecipients = mergeMinterNotificationRecipients( existingRecord.channels?.qmail?.recipients || [], nextRecord.channels?.qmail?.recipients || [] ) const mergedAnnouncement = { ...(existingRecord.channels?.announcement || {}), ...(nextRecord.channels?.announcement || {}), } return { ...existingRecord, ...nextRecord, actionKey: nextRecord.actionKey || existingRecord.actionKey || "", eventId: nextRecord.eventId || existingRecord.eventId || "", eventType: nextRecord.eventType || existingRecord.eventType || "", actionIdentifier: nextRecord.actionIdentifier || existingRecord.actionIdentifier || buildMinterNotificationActionIdentifier(nextRecord), cardIdentifier: nextRecord.cardIdentifier || existingRecord.cardIdentifier || "", nomineeName: nextRecord.nomineeName || existingRecord.nomineeName || "", nominatorName: nextRecord.nominatorName || existingRecord.nominatorName || "", summary: nextRecord.summary || existingRecord.summary || "", publishedAt: nextRecord.publishedAt || existingRecord.publishedAt || Date.now(), publishedBy: nextRecord.publishedBy || existingRecord.publishedBy || "", publishedByAddress: nextRecord.publishedByAddress || existingRecord.publishedByAddress || "", notificationGroupId: nextRecord.notificationGroupId ?? existingRecord.notificationGroupId ?? null, channels: { qchat: { ...(existingRecord.channels?.qchat || {}), ...(nextRecord.channels?.qchat || {}), published: mergedQchatRecipients.length > 0 || Boolean(existingRecord.channels?.qchat?.published) || Boolean(nextRecord.channels?.qchat?.published), recipientCount: mergedQchatRecipients.length, recipients: mergedQchatRecipients, }, qmail: { ...(existingRecord.channels?.qmail || {}), ...(nextRecord.channels?.qmail || {}), published: mergedQmailRecipients.length > 0 || Boolean(existingRecord.channels?.qmail?.published) || Boolean(nextRecord.channels?.qmail?.published), recipientCount: mergedQmailRecipients.length, recipients: mergedQmailRecipients, }, announcement: { ...mergedAnnouncement, published: Boolean(existingRecord.channels?.announcement?.published) || Boolean(nextRecord.channels?.announcement?.published), }, }, } } const mergeMinterNotificationState = (currentState = {}, stateRecord = {}) => { const nextState = normalizeMinterNotificationState(currentState) const now = Date.now() const cardIdentifier = stateRecord.cardIdentifier || "unknown" const previousCardState = nextState.cards[cardIdentifier] || { actionKeys: [], } const nextActionKeys = Array.from( new Set([...(previousCardState.actionKeys || []), stateRecord.actionKey]) ).filter(Boolean) nextState.publishedActions = { ...(nextState.publishedActions || {}), [stateRecord.actionKey]: { ...(nextState.publishedActions?.[stateRecord.actionKey] || {}), ...stateRecord, }, } nextState.cards = { ...(nextState.cards || {}), [cardIdentifier]: { ...previousCardState, cardIdentifier, actionKeys: nextActionKeys, lastActionKey: stateRecord.actionKey, lastEventType: stateRecord.eventType || previousCardState.lastEventType || "", lastEventId: stateRecord.eventId || previousCardState.lastEventId || "", lastUpdated: now, publishedBy: stateRecord.publishedBy || previousCardState.publishedBy || "", publishedByAddress: stateRecord.publishedByAddress || previousCardState.publishedByAddress || "", }, } nextState.updated = now nextState.notificationGroupId = MINTER_NOTIFICATION_GROUP_ID nextState.summary = { totalActions: Object.keys(nextState.publishedActions || {}).length, totalCards: Object.keys(nextState.cards || {}).length, } return nextState } const buildMinterNotificationEventData = async (event = {}) => { const actionKey = buildMinterNotificationActionKey(event) const normalizedEvent = { ...event, } return { ...normalizedEvent, version: MINTER_NOTIFICATION_SCHEMA_VERSION, type: "minter-board-notification-event", app: "Q-Mintership", eventId: await buildMinterNotificationEventIdentifier(normalizedEvent), created: Date.now(), actorName: normalizedEvent.actorName || userState.accountName || "", actorAddress: normalizedEvent.actorAddress || userState.accountAddress || "", actionKey, actionIdentifier: buildMinterNotificationActionIdentifier(normalizedEvent), } } const isCurrentUserDefaultNotificationRecipient = (cardIdentifier) => { const cardData = getMinterNotificationCardData(cardIdentifier) const currentName = String(userState.accountName || "").toLowerCase() const currentAddress = String(userState.accountAddress || "").toLowerCase() const nominatorName = String( cardData.nominator || cardData.publishedBy || "" ).toLowerCase() const nominatorAddress = String( cardData.nominatorAddress || cardData.publishedByAddress || "" ).toLowerCase() const nomineeName = String( cardData.nominee || cardData.creator || "" ).toLowerCase() const nomineeAddress = String( cardData.nomineeAddress || cardData.creatorAddress || "" ).toLowerCase() return ( Boolean(userState.isAdmin || userState.isMinterAdmin) || (currentName && currentName === nominatorName) || (currentAddress && currentAddress === nominatorAddress) || (currentName && currentName === nomineeName) || (currentAddress && currentAddress === nomineeAddress) ) } const updateNotificationBellState = (cardIdentifier, preference) => { const enabled = preference?.enabled !== false const buttons = Array.from( document.querySelectorAll(".card-notification-button") ).filter((button) => button.dataset.notificationCard === cardIdentifier) buttons.forEach((button) => { button.classList.toggle("card-notification-button--enabled", enabled) button.classList.toggle("card-notification-button--disabled", !enabled) button.title = enabled ? "Notifications enabled for this card" : "Notifications disabled for this card" button.setAttribute( "aria-label", enabled ? "Notifications enabled for this card" : "Notifications disabled for this card" ) }) } const toggleMinterCardNotifications = async (buttonEl) => { const cardIdentifier = String( buttonEl?.dataset?.notificationCard || "" ).trim() if (!cardIdentifier) return try { buttonEl.disabled = true const settings = await getCurrentUserMinterNotificationSettings(true) const currentPreference = getCardNotificationPreference( settings, cardIdentifier ) const nextEnabled = currentPreference ? !currentPreference.enabled : !isCurrentUserDefaultNotificationRecipient(cardIdentifier) const nextCards = { ...(settings.cards || {}), [cardIdentifier]: { ...(settings.cards?.[cardIdentifier] || {}), enabled: nextEnabled, channels: { qchat: settings.global?.qchat !== false, qmail: settings.global?.qmail === true, ...(settings.cards?.[cardIdentifier]?.channels || {}), }, events: { ...DEFAULT_MINTER_NOTIFICATION_EVENTS, ...(settings.cards?.[cardIdentifier]?.events || {}), }, updated: Date.now(), }, } const nextSettings = await publishCurrentUserMinterNotificationSettings({ ...settings, cards: nextCards, }) updateNotificationBellState( cardIdentifier, getCardNotificationPreference(nextSettings, cardIdentifier) ) } catch (error) { console.error("Unable to update notification settings:", error) alert("Unable to update notification settings. Please try again.") } finally { buttonEl.disabled = false } } const openMinterNotificationSettingsModal = async () => { ensureMinterNotificationModal() const settings = await getCurrentUserMinterNotificationSettings(true) const isGroupMember = await getCurrentUserMinterNotificationGroupMembership( true ) const modal = document.getElementById("notification-delivery-modal") const modalContent = document.getElementById( "notification-delivery-modalContent" ) if (!modal || !modalContent) return modalContent.style.overflow = "hidden" modalContent.innerHTML = `

Notification Settings

Publish how you want to receive Minter Board notifications. Card-specific bell settings are kept with this same public settings object.

Broadcast group ${qEscapeHtml( MINTER_NOTIFICATION_GROUP_NAME )} (#${qEscapeHtml(String(MINTER_NOTIFICATION_GROUP_ID))})
Status ${isGroupMember ? "Joined" : "Not joined yet"}
${ isGroupMember ? "" : `
` }
` modal.style.display = "block" } const saveMinterNotificationSettingsFromModal = async () => { const statusEl = document.getElementById("notification-delivery-status") try { if (statusEl) statusEl.textContent = "Publishing notification settings..." const settings = await getCurrentUserMinterNotificationSettings(true) const nextSettings = { ...settings, global: { ...settings.global, enabled: document.getElementById("notification-settings-enabled") ?.checked, qchat: document.getElementById("notification-settings-qchat")?.checked, qmail: document.getElementById("notification-settings-qmail")?.checked, notificationGroupId: MINTER_NOTIFICATION_GROUP_ID, }, } await publishCurrentUserMinterNotificationSettings(nextSettings) if (statusEl) { statusEl.textContent = "Notification settings published." } } catch (error) { console.error("Unable to publish notification settings:", error) if (statusEl) { statusEl.textContent = "Unable to publish notification settings." } } } const buildMinterCardNotificationButtonHtml = (cardIdentifier) => ` ` const buildMinterBoardShareLinkButtonHtml = ({ cardIdentifier = "", variant = "card", } = {}) => { const isListVariant = variant === "list" const visibleLabel = isListVariant ? "Copy link" : "Link" return ` ` } const hydrateMinterCardNotificationButton = async (cardIdentifier) => { try { const settings = await getCurrentUserMinterNotificationSettings() const preference = getCardNotificationPreference(settings, cardIdentifier) updateNotificationBellState( cardIdentifier, preference || { enabled: isCurrentUserDefaultNotificationRecipient(cardIdentifier), } ) } catch (error) { console.warn("Unable to hydrate notification button:", error) } } const getMinterNotificationCardData = (cardIdentifier) => minterBoardCardDataByIdentifier.get(cardIdentifier) || {} const resolveNotificationIdentity = async (name = "", address = "") => { let resolvedName = String(name || "").trim() let resolvedAddress = String(address || "").trim() if ( !resolvedName && resolvedAddress && typeof getNameFromAddress === "function" ) { const nameFromAddress = await getNameFromAddress(resolvedAddress) if (nameFromAddress && nameFromAddress !== resolvedAddress) { resolvedName = nameFromAddress } } if (!resolvedAddress && resolvedName) { resolvedAddress = await fetchOwnerAddressFromNameCached(resolvedName) } if (!resolvedName && resolvedAddress) { resolvedName = resolvedAddress } const publicKey = resolvedAddress ? await getPublicKeyFromAddress(resolvedAddress) : resolvedName ? await getPublicKeyByName(resolvedName) : "" return { name: resolvedName, address: resolvedAddress, publicKey: publicKey || "", } } const addNotificationRecipient = (recipientMap, recipient, source) => { if (!recipient?.name && !recipient?.address) return const key = (recipient.address || recipient.name || "").toLowerCase() const currentName = String(userState.accountName || "").toLowerCase() const currentAddress = String(userState.accountAddress || "").toLowerCase() if ( key && (key === currentAddress || String(recipient.name || "").toLowerCase() === currentName) ) { return } const existing = recipientMap.get(key) const existingChannels = existing?.channels || {} const recipientChannels = recipient.channels || {} recipientMap.set(key, { ...(existing || recipient), ...recipient, channels: { qchat: Boolean(existingChannels.qchat || recipientChannels.qchat), qmail: Boolean(existingChannels.qmail || recipientChannels.qmail), }, sources: Array.from(new Set([...(existing?.sources || []), source])), }) } const shouldNotifyFromSettings = ( settings, cardIdentifier, eventType, defaultEnabled = false ) => { const cardPreference = getCardNotificationPreference(settings, cardIdentifier) if (cardPreference) { return ( cardPreference.enabled !== false && cardPreference.events?.[eventType] !== false ) } return ( defaultEnabled && settings.global?.enabled !== false && settings.global?.events?.[eventType] !== false ) } const resolveMinterNotificationRecipients = async (event) => { const settingsList = await fetchMinterBoardNotificationSettings() const settingsByAddress = new Map() const settingsByName = new Map() settingsList.forEach((settings) => { if (settings.publisherAddress) { settingsByAddress.set(settings.publisherAddress.toLowerCase(), settings) } if (settings.publisher) { settingsByName.set(settings.publisher.toLowerCase(), settings) } }) const recipientMap = new Map() const cardIdentifier = event.cardIdentifier const cardData = getMinterNotificationCardData(cardIdentifier) const eventType = event.eventType const adminTasks = (cachedMinterAdmins || []).map((admin) => async () => { const identity = await resolveNotificationIdentity("", admin.member) const settings = settingsByAddress.get(String(identity.address || "").toLowerCase()) || settingsByName.get(String(identity.name || "").toLowerCase()) || normalizeMinterNotificationSettings({ publisher: identity.name, publisherAddress: identity.address, }) if (shouldNotifyFromSettings(settings, cardIdentifier, eventType, true)) { addNotificationRecipient( recipientMap, { ...identity, channels: getMinterNotificationChannels(settings, cardIdentifier), }, "Minter admins" ) } }) await runWithConcurrency(adminTasks, 5) const nominator = await resolveNotificationIdentity( cardData.nominator || cardData.publishedBy || "", cardData.nominatorAddress || cardData.publishedByAddress || "" ) const nominatorSettings = settingsByAddress.get(String(nominator.address || "").toLowerCase()) || settingsByName.get(String(nominator.name || "").toLowerCase()) || normalizeMinterNotificationSettings({ publisher: nominator.name, publisherAddress: nominator.address, }) if ( (nominator.name || nominator.address) && shouldNotifyFromSettings(nominatorSettings, cardIdentifier, eventType, true) ) { addNotificationRecipient( recipientMap, { ...nominator, channels: getMinterNotificationChannels( nominatorSettings, cardIdentifier ), }, "Nominator" ) } const nominee = await resolveNotificationIdentity( cardData.nominee || cardData.creator || "", cardData.nomineeAddress || cardData.creatorAddress || "" ) const nomineeSettings = settingsByAddress.get(String(nominee.address || "").toLowerCase()) || settingsByName.get(String(nominee.name || "").toLowerCase()) || normalizeMinterNotificationSettings({ publisher: nominee.name, publisherAddress: nominee.address, }) if ( (nominee.name || nominee.address) && shouldNotifyFromSettings(nomineeSettings, cardIdentifier, eventType, true) ) { addNotificationRecipient( recipientMap, { ...nominee, channels: getMinterNotificationChannels( nomineeSettings, cardIdentifier ), }, "Nominee" ) } if (event.replyTo?.creator) { const replyRecipient = await resolveNotificationIdentity( event.replyTo.creator ) const replySettings = settingsByAddress.get( String(replyRecipient.address || "").toLowerCase() ) || settingsByName.get(String(replyRecipient.name || "").toLowerCase()) || normalizeMinterNotificationSettings({ publisher: replyRecipient.name, publisherAddress: replyRecipient.address, }) if ( shouldNotifyFromSettings(replySettings, cardIdentifier, "reply", true) ) { addNotificationRecipient( recipientMap, { ...replyRecipient, channels: getMinterNotificationChannels( replySettings, cardIdentifier ), }, "Reply author" ) } } settingsList.forEach((settings) => { if ( shouldNotifyFromSettings(settings, cardIdentifier, eventType, false) && settings.cards?.[cardIdentifier]?.enabled === true ) { addNotificationRecipient( recipientMap, { name: settings.publisher, address: settings.publisherAddress, publicKey: "", channels: getMinterNotificationChannels(settings, cardIdentifier), }, "Other tracked users" ) } }) const recipients = Array.from(recipientMap.values()) const hydrateTasks = recipients.map((recipient) => async () => { if (!recipient.publicKey && recipient.address) { recipient.publicKey = await getPublicKeyFromAddress(recipient.address) } return recipient }) const hydratedRecipients = await runWithConcurrency(hydrateTasks, 5) return hydratedRecipients.filter( (recipient) => recipient.channels?.qchat || recipient.channels?.qmail ) } const getMinterNotificationPublishedActionRecord = (state, event) => { const actionKey = buildMinterNotificationActionKey(event) return state?.publishedActions?.[actionKey] || null } const isMinterNotificationRecipientPublished = ( record, recipient, channel = "qchat" ) => { const publishedRecipients = record?.channels?.[channel]?.recipients || [] const recipientKey = getMinterNotificationRecipientKey(recipient) return publishedRecipients.some( (publishedRecipient) => getMinterNotificationRecipientKey(publishedRecipient) === recipientKey ) } const splitMinterNotificationRecipientsByPendingState = ( record, recipients = [] ) => { const pendingRecipients = { qchat: [], qmail: [], } const handledRecipients = { qchat: [], qmail: [], } ;(Array.isArray(recipients) ? recipients : []).forEach((recipient) => { if (recipient.channels?.qchat) { if (isMinterNotificationRecipientPublished(record, recipient, "qchat")) { handledRecipients.qchat.push(recipient) } else { pendingRecipients.qchat.push(recipient) } } if (recipient.channels?.qmail) { if (isMinterNotificationRecipientPublished(record, recipient, "qmail")) { handledRecipients.qmail.push(recipient) } else { pendingRecipients.qmail.push(recipient) } } }) return { pendingRecipients, handledRecipients, } } const buildMinterNotificationPublishBatch = async (event = {}) => { const [currentState, recipients, eventData] = await Promise.all([ fetchMinterBoardNotificationState(), resolveMinterNotificationRecipients(event), buildMinterNotificationEventData(event), ]) const existingRecord = getMinterNotificationPublishedActionRecord( currentState, eventData ) const { pendingRecipients, handledRecipients } = splitMinterNotificationRecipientsByPendingState(existingRecord, recipients) const recipientSections = buildMinterNotificationRecipientSections(recipients) const recordRecipients = mergeMinterNotificationRecipients( pendingRecipients.qchat, pendingRecipients.qmail ) const broadcastGroupId = resolveMinterNotificationBroadcastGroupId() const draftStateRecord = buildMinterNotificationStateRecord( eventData, recordRecipients.length ? recordRecipients : recipients, broadcastGroupId ) const stateRecord = mergeMinterNotificationStateRecord( existingRecord || {}, draftStateRecord ) const nextState = mergeMinterNotificationState(currentState, stateRecord) nextState.notificationGroupId = normalizeMinterNotificationGroupId( broadcastGroupId ?? nextState.notificationGroupId ?? "" ) const eventData64 = (await objectToBase64(eventData)) || btoa(JSON.stringify(eventData)) const stateData64 = (await objectToBase64(nextState)) || btoa(JSON.stringify(nextState)) return { currentState, event: eventData, eventData64, state: nextState, stateData64, stateRecord, recipients, pendingRecipients, handledRecipients, existingRecord, broadcastGroupId, recipientSections, hasPendingRecipients: pendingRecipients.qchat.length > 0 || pendingRecipients.qmail.length > 0, resources: [ { name: userState.accountName, service: "BLOG_POST", identifier: eventData.eventId, base64: eventData64, }, { name: userState.accountName, service: "BLOG_POST", identifier: getCurrentMinterNotificationStateIdentifier(), base64: stateData64, }, ], } } const refreshMinterNotificationReviewButton = () => { const reviewButton = document.getElementById("notification-review-button") if (!reviewButton) return const batch = minterBoardNotificationDeliveryState.batch const qchatPendingCount = batch ? batch.broadcastGroupId ? batch.pendingRecipients?.qchat?.length ? 1 : 0 : batch.pendingRecipients?.qchat?.length || 0 : 0 const pendingCount = batch ? qchatPendingCount + (batch.pendingRecipients?.qmail?.length || 0) : 0 reviewButton.hidden = !batch reviewButton.textContent = pendingCount > 0 ? `Pending Notifications (${pendingCount})` : "Pending Notifications" } const getCurrentUserMinterNotificationGroupMembership = async ( force = false ) => { if (!userState.isLoggedIn) { return false } let accountAddress = String(userState.accountAddress || "").trim() if (!accountAddress && typeof getUserAddress === "function") { try { accountAddress = String((await getUserAddress()) || "").trim() } catch (error) { console.warn( "Unable to resolve current user address for notifications:", error ) accountAddress = "" } } if (!accountAddress) { return false } const now = Date.now() const cachedMatch = minterBoardNotificationGroupMembershipState.accountAddress === accountAddress && now - minterBoardNotificationGroupMembershipState.timestamp < MINTER_NOTIFICATION_GROUP_MEMBERSHIP_CACHE_TTL_MS if (!force && cachedMatch) { return Boolean(minterBoardNotificationGroupMembershipState.isMember) } try { const groups = await getUserGroups(accountAddress) const isMember = Array.isArray(groups) ? groups.some( (group) => Number(group.groupId) === MINTER_NOTIFICATION_GROUP_ID || String(group.groupName || "").toLowerCase() === MINTER_NOTIFICATION_GROUP_NAME.toLowerCase() ) : false minterBoardNotificationGroupMembershipState.timestamp = now minterBoardNotificationGroupMembershipState.accountAddress = accountAddress minterBoardNotificationGroupMembershipState.isMember = isMember return isMember } catch (error) { console.warn("Unable to load notification group membership:", error) minterBoardNotificationGroupMembershipState.timestamp = now minterBoardNotificationGroupMembershipState.accountAddress = accountAddress minterBoardNotificationGroupMembershipState.isMember = false return false } } const refreshMinterNotificationGroupPrompt = async () => { const prompt = document.getElementById("notification-group-prompt") if (!prompt) return if (!userState.isLoggedIn) { prompt.hidden = true prompt.innerHTML = "" return } const isMember = await getCurrentUserMinterNotificationGroupMembership() if (isMember) { prompt.hidden = true prompt.innerHTML = "" return } const joinDisabled = minterBoardNotificationGroupMembershipState.inFlight ? "disabled" : "" const joinLabel = minterBoardNotificationGroupMembershipState.inFlight ? "Joining..." : "Join Notifications Group" prompt.innerHTML = `
Join Q-Chat notifications ${qEscapeHtml(MINTER_NOTIFICATION_GROUP_NAME)} (#${qEscapeHtml( String(MINTER_NOTIFICATION_GROUP_ID) )}) is where broadcast notifications are delivered.
` prompt.hidden = false } const joinMinterNotificationGroup = async () => { if (minterBoardNotificationGroupMembershipState.inFlight) { return false } const alreadyMember = await getCurrentUserMinterNotificationGroupMembership() if (alreadyMember) { await refreshMinterNotificationGroupPrompt() return true } minterBoardNotificationGroupMembershipState.inFlight = true await refreshMinterNotificationGroupPrompt() try { const joinRequest = await qortalRequest({ action: "JOIN_GROUP", groupId: MINTER_NOTIFICATION_GROUP_ID, }) if (!joinRequest) { throw new Error("JOIN_GROUP returned no response.") } minterBoardNotificationGroupMembershipState.timestamp = 0 const isMember = await getCurrentUserMinterNotificationGroupMembership(true) await refreshMinterNotificationGroupPrompt() if ( document.getElementById("notification-delivery-modal")?.style.display === "block" && document .getElementById("notification-delivery-modalContent") ?.querySelector("h2")?.textContent === "Notification Settings" ) { await openMinterNotificationSettingsModal() } alert( isMember ? `Joined ${MINTER_NOTIFICATION_GROUP_NAME}.` : `Join request for ${MINTER_NOTIFICATION_GROUP_NAME} was submitted.` ) return true } catch (error) { console.error("Unable to join notifications group:", error) alert("Unable to join the notifications group right now.") return false } finally { minterBoardNotificationGroupMembershipState.inFlight = false await refreshMinterNotificationGroupPrompt() } } const getMinterNotificationEventContext = (event = {}) => { const cardData = getMinterNotificationCardData(event.cardIdentifier) const nomineeName = String( event.nomineeName || getCardNomineeName(cardData, "a nominee") || "a nominee" ).trim() const nominatorName = String( event.nominatorName || getCardNominatorName(cardData, "a nominator") || "a nominator" ).trim() const actorName = String( event.actorName || userState.accountName || "Someone" ).trim() const replyAuthorName = String( event.replyTo?.creator || event.replyTo?.authorName || event.replyTo?.publisher || "" ).trim() const nominationTimestamp = Number( event.nominationTimestamp || cardData.timestamp || cardData.created || cardData.updated || event.created || Date.now() ) const nominationPublishDate = new Date( Number.isFinite(nominationTimestamp) ? nominationTimestamp : Date.now() ).toLocaleString() return { cardData, nomineeName, nominatorName, actorName, replyAuthorName, nominationTimestamp, nominationPublishDate, } } const getMinterNotificationEventTitle = (event) => { const { nomineeName, actorName } = getMinterNotificationEventContext(event) const labels = { comment: `Comment on ${nomineeName}'s nomination by ${actorName}`, reply: `Reply on ${nomineeName}'s nomination by ${actorName}`, admin_vote: `${actorName} cast an admin vote on ${nomineeName}'s nomination`, minter_vote: `${actorName} cast a minter vote on ${nomineeName}'s nomination`, user_vote: `${actorName} voted on ${nomineeName}'s nomination`, invite_created: `${actorName} started the invite process for ${nomineeName}`, group_approval: `${actorName} approved a pending invite transaction for ${nomineeName}`, joined: `${nomineeName} joined the MINTER group`, } return ( labels[event.eventType] || `${actorName} updated ${nomineeName}'s nomination` ) } const buildMinterNotificationRichTextTextNode = (text, marks = []) => { const normalizedText = String(text ?? "") const normalizedMarks = Array.isArray(marks) ? marks.filter(Boolean) : [] const node = { type: "text", text: normalizedText, } if (normalizedMarks.length > 0) { node.marks = normalizedMarks } return node } const buildMinterNotificationRichTextParagraphNode = (content = []) => { const normalizedContent = Array.isArray(content) ? content.filter(Boolean) : [] return { type: "paragraph", content: normalizedContent, } } const buildMinterNotificationRichTextListItemNode = (label, value) => { return { type: "listItem", content: [ buildMinterNotificationRichTextParagraphNode([ buildMinterNotificationRichTextTextNode(`${label}: `, [ { type: "bold" }, ]), buildMinterNotificationRichTextTextNode(value), ]), ], } } const buildMinterNotificationRichTextBlockquoteNodes = (text) => { const normalizedText = String(text ?? "").trim() if (!normalizedText) { return [] } const paragraphNodes = normalizedText .split(/\n+/) .map((line) => String(line || "").trim()) .filter(Boolean) .map((line) => buildMinterNotificationRichTextParagraphNode([ buildMinterNotificationRichTextTextNode(line), ]) ) return [ { type: "blockquote", content: paragraphNodes, }, ] } const buildMinterNotificationRichTextDoc = (event = {}) => { const { nomineeName, nominatorName, actorName, replyAuthorName, nominationPublishDate, } = getMinterNotificationEventContext(event) const title = getMinterNotificationEventTitle(event) const detailItems = [ ["Nominee", nomineeName], ["Nominator", nominatorName], ["Action by", actorName], ["Published", nominationPublishDate], event.eventType === "reply" && replyAuthorName ? ["In reply to", replyAuthorName] : null, ].filter( (item) => Array.isArray(item) && String(item[1] ?? "").trim().length > 0 ) const summary = String(event.summary || "").trim() return { messageText: { type: "doc", content: [ { type: "heading", attrs: { level: 3, }, content: [buildMinterNotificationRichTextTextNode(title)], }, { type: "bulletList", content: detailItems.map(([label, value]) => buildMinterNotificationRichTextListItemNode(label, value) ), }, ...buildMinterNotificationRichTextBlockquoteNodes(summary), buildMinterNotificationRichTextParagraphNode([ buildMinterNotificationRichTextTextNode( "Open Q-Mintership and load the Minter Board to review the latest data." ), ]), ], }, version: 3, } } const sendMinterNotificationChatMessage = async ({ groupId = null, recipient = null, message, fullContent, }) => { const requestPayload = { action: "SEND_CHAT_MESSAGE", message, ...(fullContent ? { fullContent } : {}), ...(groupId !== null && groupId !== undefined ? { groupId } : recipient !== null && recipient !== undefined ? { recipient } : {}), } try { return await qortalRequest(requestPayload) } catch (error) { const errorText = String(error?.message || error || "") if (fullContent && /fullcontent/i.test(errorText)) { console.warn( "Rich notification chat payload was rejected; retrying with plain text.", error ) const fallbackPayload = { ...requestPayload } delete fallbackPayload.fullContent return await qortalRequest(fallbackPayload) } throw error } } const buildMinterNotificationMessage = (event) => { const { nomineeName, nominatorName, actorName, replyAuthorName, nominationPublishDate, } = getMinterNotificationEventContext(event) const title = getMinterNotificationEventTitle(event) const lines = [ title, "", `Nominee: ${nomineeName}`, `Nominator: ${nominatorName}`, `Action by: ${actorName}`, `Published: ${nominationPublishDate}`, event.eventType === "reply" && replyAuthorName ? `In reply to: ${replyAuthorName}` : "", event.summary ? `Details: ${event.summary}` : "", "", "Open Q-Mintership and load the Minter Board to review the latest data.", ].filter((line) => line !== "") return lines.join("\n") } const MINTER_NOTIFICATION_RECIPIENT_SECTION_ORDER = [ "admins", "nominator", "nominee", "reply", "watchers", ] const MINTER_NOTIFICATION_RECIPIENT_SECTION_LABELS = { admins: "Minter admins", nominator: "Nominator", nominee: "Nominee", reply: "Reply author", watchers: "Other tracked users", } const getMinterNotificationRecipientSectionKey = (recipient = {}) => { const sources = Array.isArray(recipient.sources) ? recipient.sources : [] const sourceText = sources.join(" ").toLowerCase() if (sourceText.includes("admin")) return "admins" if (sourceText.includes("nominator")) return "nominator" if (sourceText.includes("nominee")) return "nominee" if (sourceText.includes("reply")) return "reply" return "watchers" } const getMinterNotificationRecipientSectionLabel = (sectionKey = "") => MINTER_NOTIFICATION_RECIPIENT_SECTION_LABELS[sectionKey] || MINTER_NOTIFICATION_RECIPIENT_SECTION_LABELS.watchers const buildMinterNotificationRecipientSections = (recipients = []) => { const sectionMap = new Map( MINTER_NOTIFICATION_RECIPIENT_SECTION_ORDER.map((sectionKey) => [ sectionKey, { key: sectionKey, label: getMinterNotificationRecipientSectionLabel(sectionKey), recipients: [], }, ]) ) ;(Array.isArray(recipients) ? recipients : []).forEach((recipient) => { const sectionKey = getMinterNotificationRecipientSectionKey(recipient) const section = sectionMap.get(sectionKey) || sectionMap.get("watchers") section.recipients.push(recipient) }) return MINTER_NOTIFICATION_RECIPIENT_SECTION_ORDER.map((sectionKey) => { const section = sectionMap.get(sectionKey) section.recipients.sort((left, right) => { const leftName = String(left.name || left.address || "").toLowerCase() const rightName = String(right.name || right.address || "").toLowerCase() return leftName.localeCompare(rightName) }) return section }).filter((section) => section.recipients.length > 0) } const resolveMinterNotificationQortalData64 = (response) => { if (typeof response === "string") { return response } if (!response || typeof response !== "object") { return null } const record = response for (const key of [ "encryptedData", "decryptedData", "data64", "data", "base64", ]) { const value = record[key] if (typeof value === "string" && value.length > 0) { return value } } return null } const ensureMinterNotificationBase64 = async (value) => { let data64 = await objectToBase64(value) if (!data64) { data64 = btoa(JSON.stringify(value)) } return data64 } const encryptMinterNotificationGroupData = async (value, groupId) => { const base64 = typeof value === "string" ? value : await ensureMinterNotificationBase64(value) const response = await qortalRequest({ action: "ENCRYPT_QORTAL_GROUP_DATA", base64, groupId, }) const encrypted = resolveMinterNotificationQortalData64(response) if (!encrypted) { throw new Error("Failed to encrypt notification group data") } return encrypted } const buildMinterNotificationAnnouncementBodyHtml = (event) => { const { nomineeName, nominatorName, actorName, replyAuthorName, nominationPublishDate, } = getMinterNotificationEventContext(event) const actorLabel = event.eventType === "comment" ? "Comment by" : event.eventType === "reply" ? "Reply by" : "Action by" const details = [ `

Nominee: ${qEscapeHtml(nomineeName)}

`, `

Nominator: ${qEscapeHtml(nominatorName)}

`, `

${qEscapeHtml(actorLabel)}: ${qEscapeHtml( actorName )}

`, `

nominationPublishDate: ${qEscapeHtml( nominationPublishDate )}

`, event.eventType === "reply" && replyAuthorName ? `

inReplyTo: ${qEscapeHtml(replyAuthorName)}

` : "", event.summary ? `

${qEscapeHtml(event.summary)}

` : "", `

Open Q-Mintership and load the Minter Board to review the latest data.

`, ].filter(Boolean) return details.join("") } const buildMinterNotificationAnnouncementMessage = (event) => { const title = getMinterNotificationEventTitle(event) return `

${qEscapeHtml( title )}

${buildMinterNotificationAnnouncementBodyHtml(event)}` } const buildMinterNotificationAnnouncementPayload = (event = {}) => ({ version: MINTER_NOTIFICATION_SCHEMA_VERSION, extra: {}, message: buildMinterNotificationAnnouncementMessage(event), }) const buildMinterNotificationAnnouncementResource = async ( event = {}, groupId = MINTER_NOTIFICATION_GROUP_ID ) => { const safeGroupId = normalizeMinterNotificationGroupId(groupId) || MINTER_NOTIFICATION_GROUP_ID const identifier = `grp-${safeGroupId}-anc-${await uid()}` const payload = buildMinterNotificationAnnouncementPayload(event) const base64 = await ensureMinterNotificationBase64(payload) return { name: userState.accountName, service: "DOCUMENT", identifier, base64, } } const getMinterNotificationRecipientDisplayName = (recipient = {}) => String(recipient.name || recipient.address || "Unknown recipient") const setMinterNotificationRecipientChannelSelections = (checked) => { const checkboxes = Array.from( document.querySelectorAll(".notification-recipient-channel-checkbox") ) checkboxes.forEach((checkbox) => { if (!checkbox.disabled) { checkbox.checked = Boolean(checked) } }) } const setMinterNotificationRecipientChannelCheckboxState = ( recipientKey, channel, checked, disabled = false ) => { const key = String(recipientKey || "").trim() const normalizedChannel = String(channel || "").trim() Array.from( document.querySelectorAll(".notification-recipient-channel-checkbox") ) .filter((checkbox) => { return ( String(checkbox.dataset.recipientKey || "").trim() === key && String(checkbox.dataset.channel || "").trim() === normalizedChannel ) }) .forEach((checkbox) => { checkbox.checked = Boolean(checked) checkbox.disabled = Boolean(disabled) }) } const setMinterNotificationGroupCheckboxState = (checked, disabled = false) => { const checkbox = document.getElementById("notification-send-qchat") if (!checkbox) return checkbox.checked = Boolean(checked) checkbox.disabled = Boolean(disabled) } const toggleMinterNotificationIndividualRecipientsVisibility = (checked) => { const section = document.getElementById( "notification-individual-recipient-section" ) if (!section) return section.hidden = !Boolean(checked) } const ensureMinterNotificationModal = () => { createModal("notification-delivery") } const openMinterNotificationDeliveryModal = async (batch) => { if (!batch) { return } ensureMinterNotificationModal() minterBoardNotificationDeliveryState.batch = batch refreshMinterNotificationReviewButton() const modal = document.getElementById("notification-delivery-modal") const modalContent = document.getElementById( "notification-delivery-modalContent" ) if (!modal || !modalContent) return const pendingRecipients = batch.pendingRecipients || { qchat: [], qmail: [] } const handledRecipients = batch.handledRecipients || { qchat: [], qmail: [] } const allRecipients = Array.isArray(batch.recipients) ? batch.recipients : [] const recipientSections = Array.isArray(batch.recipientSections) && batch.recipientSections.length > 0 ? batch.recipientSections : buildMinterNotificationRecipientSections(allRecipients) const currentDeliveryState = batch.deliveryState || {} const isGroupMember = await getCurrentUserMinterNotificationGroupMembership() const broadcastGroupId = normalizeMinterNotificationGroupId( batch.broadcastGroupId ?? "" ) const broadcastGroupLabel = `${MINTER_NOTIFICATION_GROUP_NAME} (#${ broadcastGroupId || MINTER_NOTIFICATION_GROUP_ID })` const qchatPendingCount = pendingRecipients.qchat.length const qmailPendingCount = pendingRecipients.qmail.length const qchatHandledCount = handledRecipients.qchat.length const qmailHandledCount = handledRecipients.qmail.length const hasDirectRecipients = recipientSections.length > 0 const qchatGroupAlreadySent = Boolean(currentDeliveryState.qchatBroadcastSent) const announcementAlreadyPublished = Boolean( currentDeliveryState.announcementPublished ) const qchatGroupDisabled = !isGroupMember || qchatGroupAlreadySent const announcementDisabled = !isGroupMember const alreadySentQchatRecipients = new Set( Array.isArray(currentDeliveryState.directQchatRecipientKeysSent) ? currentDeliveryState.directQchatRecipientKeysSent : [] ) const alreadyPublishedQmailRecipients = new Set( Array.isArray(currentDeliveryState.directQmailRecipientKeysPublished) ? currentDeliveryState.directQmailRecipientKeysPublished : [] ) modalContent.style.overflow = "hidden" modalContent.innerHTML = `

Review Notification Publish

${qEscapeHtml(getMinterNotificationEventTitle(batch.event))}

Direct recipients ${qEscapeHtml(String(allRecipients.length))} tracked user${ allRecipients.length === 1 ? "" : "s" }
Pending Q-Chat ${qEscapeHtml( String(qchatPendingCount) )} pending, ${qEscapeHtml( String(qchatHandledCount) )} already published
Pending Q-Mail ${qEscapeHtml( String(qmailPendingCount) )} pending, ${qEscapeHtml( String(qmailHandledCount) )} already published
Broadcast group ${qEscapeHtml(broadcastGroupLabel)}
State ${qEscapeHtml( getCurrentMinterNotificationStateIdentifier() )}
Hub announcement ${ announcementAlreadyPublished ? "Already published in this session" : "Optional off by Default unless Enabled" }
Group delivery The group Q-Chat is the default path. The Hub announcement publishes one encrypted QDN resource with the Hub-compatible announcement payload.
${ !isGroupMember ? `
Join required for group delivery You can still send direct Q-Chat and Q-Mail notifications now, but the group broadcast and Hub announcement need you to join ${qEscapeHtml( MINTER_NOTIFICATION_GROUP_NAME )} first.
` : "" } ${ hasDirectRecipients ? ` ` : `
No direct recipients are pending for this event. You can still publish the group broadcast and Hub announcement.
` }
Published state preview Card ${qEscapeHtml( batch.event.cardIdentifier || "unknown" )} Action ${qEscapeHtml( batch.stateRecord?.actionKey || "pending" )} Stored actions ${qEscapeHtml( String(batch.state?.summary?.totalActions || 0) )} Broadcast group ${qEscapeHtml(broadcastGroupLabel)} Announcement ${ announcementAlreadyPublished ? "published" : "not selected" }
` modal.style.display = "block" } const notifyMinterBoardEvent = async (event) => { try { if (!userState.accountName) return const eventContext = getMinterNotificationEventContext(event) const normalizedEvent = { ...event, nomineeName: event.nomineeName || eventContext.nomineeName, nominatorName: event.nominatorName || eventContext.nominatorName, nominationTimestamp: event.nominationTimestamp || eventContext.nominationTimestamp, nominationPublishDate: event.nominationPublishDate || eventContext.nominationPublishDate, replyAuthorName: event.replyAuthorName || event.replyTo?.creator || eventContext.replyAuthorName, } const batch = await buildMinterNotificationPublishBatch(normalizedEvent) await openMinterNotificationDeliveryModal(batch) } catch (error) { console.warn("Unable to prepare notification event:", error) } } const buildQmailIdentifier = async (recipient, event = {}) => { const safeName = String(recipient.name || "recipient") .slice(0, 12) .replace(/\s+/g, "") const suffix = String(recipient.address || "").slice(-6) || "000000" const randomPart = await uid() const safeAction = String(event.eventType || "notification") .replace(/[^a-zA-Z0-9_-]/g, "_") .slice(0, 12) return `${MINTER_NOTIFICATION_QMAIL_IDENTIFIER_PREFIX}${safeName}_${suffix}_mail_${ safeAction || "notification" }_${randomPart}` } const buildMinterNotificationQmailResource = async ( recipient, event, message ) => { if (!recipient.name || !recipient.address || !recipient.publicKey) return null const payload = { subject: getMinterNotificationEventTitle(event), createdAt: Date.now(), version: 1, attachments: [], textContentV2: message, generalData: { thread: [], threadV2: [] }, recipient: recipient.name, } const base64 = await ensureMinterNotificationBase64(payload) const encrypted = await qortalRequest({ action: "ENCRYPT_DATA", base64, publicKeys: [recipient.publicKey], }) const encryptedData = resolveMinterNotificationQortalData64(encrypted) if (!encryptedData) return null return { name: userState.accountName, service: "MAIL_PRIVATE", identifier: await buildQmailIdentifier(recipient, event), base64: encryptedData, } } const sendMinterBoardNotificationDeliveries = async () => { const statusEl = document.getElementById("notification-delivery-status") const batch = minterBoardNotificationDeliveryState.batch if (!batch || minterBoardNotificationDeliveryState.isPublishing) { return } const sendGroupQchat = Boolean( document.getElementById("notification-send-qchat")?.checked ) const sendAnnouncement = Boolean( document.getElementById("notification-send-announcement")?.checked ) const sendIndividualNotifications = Boolean( document.getElementById("notification-send-individual")?.checked ) const broadcastGroupId = resolveMinterNotificationBroadcastGroupId() || MINTER_NOTIFICATION_GROUP_ID const deliveryState = batch.deliveryState || (batch.deliveryState = {}) const recipientMap = new Map( (Array.isArray(batch.recipients) ? batch.recipients : []).map( (recipient) => [getMinterNotificationRecipientKey(recipient), recipient] ) ) const selectedRecipientMap = new Map() const previouslySentQchatKeys = new Set( Array.isArray(deliveryState.directQchatRecipientKeysSent) ? deliveryState.directQchatRecipientKeysSent : [] ) const previouslyPublishedQmailKeys = new Set( Array.isArray(deliveryState.directQmailRecipientKeysPublished) ? deliveryState.directQmailRecipientKeysPublished : [] ) if (sendIndividualNotifications) { document .querySelectorAll(".notification-recipient-channel-checkbox") .forEach((checkbox) => { if (!checkbox.checked || checkbox.disabled) return const recipientKey = String(checkbox.dataset.recipientKey || "").trim() const channel = String(checkbox.dataset.channel || "").trim() const recipient = recipientMap.get(recipientKey) if (!recipient || !channel) return const existing = selectedRecipientMap.get(recipientKey) || { ...recipient, channels: { qchat: false, qmail: false, }, } existing.channels = { qchat: Boolean(existing.channels?.qchat || channel === "qchat"), qmail: Boolean(existing.channels?.qmail || channel === "qmail"), } selectedRecipientMap.set(recipientKey, existing) }) } const selectedDirectRecipients = sendIndividualNotifications ? Array.from(selectedRecipientMap.values()) : [] const selectedDirectQchatRecipients = selectedDirectRecipients.filter( (recipient) => recipient.channels?.qchat && !previouslySentQchatKeys.has(getMinterNotificationRecipientKey(recipient)) ) const selectedDirectQmailRecipients = selectedDirectRecipients.filter( (recipient) => recipient.channels?.qmail && !previouslyPublishedQmailKeys.has( getMinterNotificationRecipientKey(recipient) ) ) if ( !sendGroupQchat && !sendAnnouncement && selectedDirectQchatRecipients.length === 0 && selectedDirectQmailRecipients.length === 0 ) { alert("Select at least one notification delivery to publish.") return } minterBoardNotificationDeliveryState.isPublishing = true try { if (statusEl) statusEl.textContent = "Preparing notification deliveries..." const message = buildMinterNotificationMessage(batch.event) const fullContent = buildMinterNotificationRichTextDoc(batch.event) const markDeliveryChannel = (recipient, channel) => ({ ...recipient, channels: { qchat: channel === "qchat", qmail: channel === "qmail", }, }) let qchatGroupSent = false let qchatDirectCount = 0 let announcementResource = null const successfulQchatRecipients = [] const qmailResources = [] const preparedQmailRecipients = [] const qmailRecipientKeys = [] if (sendGroupQchat && !deliveryState.qchatBroadcastSent) { try { await sendMinterNotificationChatMessage({ groupId: broadcastGroupId, message, fullContent, }) qchatGroupSent = true deliveryState.qchatBroadcastSent = true setMinterNotificationGroupCheckboxState(true, true) } catch (error) { console.warn("Notification Q-Chat group delivery failed:", error) } } for (const recipient of selectedDirectQchatRecipients) { try { await sendMinterNotificationChatMessage({ recipient: recipient.address || recipient.name, message, fullContent, }) qchatDirectCount += 1 const recipientKey = getMinterNotificationRecipientKey(recipient) if (recipientKey) { previouslySentQchatKeys.add(recipientKey) setMinterNotificationRecipientChannelCheckboxState( recipientKey, "qchat", true, true ) } successfulQchatRecipients.push(markDeliveryChannel(recipient, "qchat")) } catch (error) { console.warn( "Notification direct Q-Chat delivery failed for recipient:", recipient, error ) } } for (const recipient of selectedDirectQmailRecipients) { try { const resource = await buildMinterNotificationQmailResource( recipient, batch.event, message ) if (resource) { qmailResources.push(resource) preparedQmailRecipients.push(recipient) qmailRecipientKeys.push(getMinterNotificationRecipientKey(recipient)) } } catch (error) { console.warn( "Notification Q-Mail preparation failed for recipient:", recipient, error ) } } if (sendAnnouncement) { try { announcementResource = await buildMinterNotificationAnnouncementResource( batch.event, broadcastGroupId ) } catch (error) { console.warn( "Notification group announcement preparation failed:", error ) announcementResource = null } } const qdnRecipientsForState = mergeMinterNotificationRecipients( successfulQchatRecipients, preparedQmailRecipients.map((recipient) => markDeliveryChannel(recipient, "qmail") ) ) const deliveryMeta = { qchatBroadcastSent: qchatGroupSent, announcementPublished: Boolean(announcementResource), announcementGroupId: announcementResource ? broadcastGroupId : null, announcementIdentifier: announcementResource?.identifier || "", } const stateRecord = mergeMinterNotificationStateRecord( batch.existingRecord || {}, buildMinterNotificationStateRecord( batch.event, qdnRecipientsForState, broadcastGroupId, deliveryMeta ) ) const nextState = mergeMinterNotificationState( batch.currentState || {}, stateRecord ) nextState.notificationGroupId = normalizeMinterNotificationGroupId( broadcastGroupId ?? nextState.notificationGroupId ?? "" ) const eventData64 = batch.eventData64 || (await ensureMinterNotificationBase64(batch.event)) const stateData64 = (await ensureMinterNotificationBase64(nextState)) || btoa(JSON.stringify(nextState)) const resources = [ { name: userState.accountName, service: "BLOG_POST", identifier: batch.event.eventId, base64: eventData64, }, { name: userState.accountName, service: "BLOG_POST", identifier: getCurrentMinterNotificationStateIdentifier(), base64: stateData64, }, ...qmailResources, ...(announcementResource ? [announcementResource] : []), ] if (statusEl) { const qchatSelectionText = qchatGroupSent ? "group broadcast" : "group broadcast skipped" statusEl.textContent = `Publishing ${qchatSelectionText}, ${qchatDirectCount} direct Q-Chat message${ qchatDirectCount === 1 ? "" : "s" }, ${qmailResources.length} Q-Mail notification${ qmailResources.length === 1 ? "" : "s" }${announcementResource ? ", and 1 Hub announcement" : ""}...` } await qortalRequest({ action: "PUBLISH_MULTIPLE_QDN_RESOURCES", resources, }) deliveryState.statePublished = true deliveryState.directQchatRecipientKeysSent = Array.from( new Set([...previouslySentQchatKeys]) ) if (qmailRecipientKeys.length > 0) { deliveryState.directQmailRecipientKeysPublished = Array.from( new Set([ ...previouslyPublishedQmailKeys, ...qmailRecipientKeys.filter(Boolean), ]) ) } if (announcementResource) { deliveryState.announcementPublished = true deliveryState.announcementIdentifier = announcementResource.identifier } minterBoardNotificationStateCache.timestamp = 0 minterBoardNotificationStateCache.data = [nextState] minterBoardNotificationDeliveryState.batch = null refreshMinterNotificationReviewButton() if (statusEl) { if ( qchatGroupSent || qchatDirectCount > 0 || qmailResources.length > 0 || announcementResource ) { statusEl.textContent = `Published shared state plus ${ qchatGroupSent ? "1 Q-Chat group broadcast, " : "" }${qchatDirectCount} direct Q-Chat notification${ qchatDirectCount === 1 ? "" : "s" }, ${qmailResources.length} Q-Mail notification${ qmailResources.length === 1 ? "" : "s" }${ announcementResource ? ", and 1 Hub-compatible group announcement" : "" }.` } else { statusEl.textContent = "Published shared state, but none of the selected deliveries succeeded." } } window.setTimeout(() => { closeModal("notification-delivery") }, 900) } catch (error) { console.error("Unable to publish notifications:", error) if (statusEl) { if (deliveryState.qchatBroadcastSent) { statusEl.textContent = "The Q-Chat group broadcast succeeded, but the combined notification publish did not complete. You can retry the remaining QDN resources without resending the group chat." } else if ( Array.isArray(deliveryState.directQchatRecipientKeysSent) && deliveryState.directQchatRecipientKeysSent.length > 0 ) { statusEl.textContent = "Some direct Q-Chat messages were sent, but the combined notification publish did not complete. You can retry the remaining QDN resources without resending those direct messages." } else { statusEl.textContent = "Unable to publish notifications. Review the selections and try again." } } } finally { minterBoardNotificationDeliveryState.isPublishing = false } } const updateMinterBoardCounterText = () => { const counterSpan = minterBoardInfiniteState.counterSpan if (!counterSpan) return const displayed = minterBoardInfiniteState.displayedCount const minted = minterBoardInfiniteState.mintedCount const total = minterBoardInfiniteState.totalCount || minterBoardInfiniteState.cards.length || 0 if (minterBoardInfiniteState.isBackgroundLoading && total > 0) { const loadingHtml = typeof getBoardInlineLoadingHTML === "function" ? getBoardInlineLoadingHTML( `Loading cards ${Math.min(displayed, total)}/${total}` ) : "Loading cards..." counterSpan.innerHTML = `${loadingHtml} (${minted} minters)` return } counterSpan.textContent = `(${displayed} displayed, ${minted} minters)` } const maybeRenderMoreMinterBoardCards = async (loadToken) => { if (loadToken !== minterBoardInfiniteState.loadToken) return if (minterBoardInfiniteState.inFlight || minterBoardInfiniteState.complete) return await renderMinterBoardCardBatch(loadToken) } const startMinterBoardBackgroundRender = (loadToken) => { if (minterBoardInfiniteState.backgroundRunnerToken === loadToken) return minterBoardInfiniteState.backgroundRunnerToken = loadToken const run = async () => { try { while ( loadToken === minterBoardInfiniteState.loadToken && !minterBoardInfiniteState.complete ) { await maybeRenderMoreMinterBoardCards(loadToken) await new Promise((resolve) => setTimeout(resolve, 0)) } } catch (error) { console.warn("Error during minter board background render:", error) } finally { if (minterBoardInfiniteState.backgroundRunnerToken === loadToken) { minterBoardInfiniteState.backgroundRunnerToken = 0 } } } run() } const renderMinterBoardCardBatch = async (loadToken) => { // Kakashi Note: Load token checks cancel stale render work when filters or sorts change mid-load. if (loadToken !== minterBoardInfiniteState.loadToken) return if (minterBoardInfiniteState.inFlight || minterBoardInfiniteState.complete) return const cardsContainer = minterBoardInfiniteState.container if (!cardsContainer || !document.body.contains(cardsContainer)) { minterBoardInfiniteState.complete = true minterBoardInfiniteState.inFlight = false minterBoardInfiniteState.isBackgroundLoading = false detachMinterBoardInfiniteScroll() updateMinterBoardCounterText() return } const start = minterBoardInfiniteState.cursor const end = Math.min( start + MINTER_SCROLL_BATCH_SIZE, minterBoardInfiniteState.cards.length ) if (start >= end) { minterBoardInfiniteState.complete = true minterBoardInfiniteState.isBackgroundLoading = false updateMinterBoardCounterText() return } const batch = minterBoardInfiniteState.cards.slice(start, end) minterBoardInfiniteState.cursor = end minterBoardInfiniteState.inFlight = true // Kakashi Note: Insert skeletons first so users see progress immediately while details finalize concurrently. for (const card of batch) { if (loadToken !== minterBoardInfiniteState.loadToken) { minterBoardInfiniteState.inFlight = false return } cardsContainer.insertAdjacentHTML( "beforeend", createSkeletonCardHTML(card.identifier) ) } const finalizeTasks = batch.map((card) => { return async () => { if (loadToken !== minterBoardInfiniteState.loadToken) return try { const data = await fetchMinterBoardCardDataCached(card) if (!data || !data.poll) { if (loadToken === minterBoardInfiniteState.loadToken) { removeSkeleton(card.identifier) } return } if (!card._optimisticCard) { const pollPublisherAddress = await getPollOwnerAddressCached( data.poll ) const cardPublisherAddress = await fetchOwnerAddressFromNameCached( card.name ) if (pollPublisherAddress !== cardPublisherAddress) { if (loadToken === minterBoardInfiniteState.loadToken) { removeSkeleton(card.identifier) } return } } if (minterBoardInfiniteState.isARBoard) { const ok = await verifyMinterCached(data.minterName) if (!ok) { if (loadToken === minterBoardInfiniteState.loadToken) { removeSkeleton(card.identifier) } return } } else { const isAlready = await verifyMinterCached( getCardNomineeName(data, getCardNomineeAddress(data)) ) if (isAlready) { minterBoardInfiniteState.mintedCount += 1 updateMinterBoardCounterText() if (!minterBoardInfiniteState.showExisting) { if (loadToken === minterBoardInfiniteState.loadToken) { removeSkeleton(card.identifier) } return } const cardUpdatedTime = card.updated || card.created || null const bgColor = generateDarkPastelBackgroundBy(card.name) const commentCount = await countCommentsCached( card.identifier, loadToken ).catch(() => 0) const finalCardHTML = await createCardHTML( data, null, card.identifier, commentCount, cardUpdatedTime, bgColor, getCardNomineeAddress(data, card.name || ""), /* isExistingMinter= */ true ) if (loadToken === minterBoardInfiniteState.loadToken) { minterBoardInfiniteState.displayedCount += 1 updateMinterBoardCounterText() replaceSkeleton(card.identifier, finalCardHTML) void hydrateMinterCardNotificationButton(card.identifier) void hydrateMinterBoardCommentCount(card.identifier, loadToken) void hydrateMinterBoardCardDisplay({ cardResource: card, cardData: data, cardIdentifier: card.identifier, isExistingMinter: true, loadToken, }) } return } } const cardUpdatedTime = card.updated || card.created || null const bgColor = generateDarkPastelBackgroundBy(card.name) const commentCount = await countCommentsCached( card.identifier, loadToken ).catch(() => 0) const pollResults = minterBoardInfiniteState.isARBoard ? await fetchPollResultsCached(data.poll) : null const finalCardHTML = minterBoardInfiniteState.isARBoard ? await createARCardHTML( data, pollResults, card.identifier, commentCount, cardUpdatedTime, bgColor, await fetchOwnerAddressFromNameCached(card.name), card.isDuplicate ) : await createCardHTML( data, null, card.identifier, commentCount, cardUpdatedTime, bgColor, getCardNomineeAddress(data, card.name || "") ) if (loadToken === minterBoardInfiniteState.loadToken) { minterBoardInfiniteState.displayedCount += 1 updateMinterBoardCounterText() replaceSkeleton(card.identifier, finalCardHTML) void hydrateMinterCardNotificationButton(card.identifier) void hydrateMinterBoardCommentCount(card.identifier, loadToken) if (!minterBoardInfiniteState.isARBoard) { void hydrateMinterBoardCardDisplay({ cardResource: card, cardData: data, cardIdentifier: card.identifier, isExistingMinter: false, loadToken, }) } } } catch (error) { console.error(`Error finalizing card ${card.identifier}:`, error) if (loadToken === minterBoardInfiniteState.loadToken) { removeSkeleton(card.identifier) } } } }) try { await runWithConcurrency(finalizeTasks, 8) } finally { minterBoardInfiniteState.inFlight = false } if (loadToken !== minterBoardInfiniteState.loadToken) return if ( minterBoardInfiniteState.cursor >= minterBoardInfiniteState.cards.length ) { minterBoardInfiniteState.complete = true minterBoardInfiniteState.isBackgroundLoading = false } updateMinterBoardCounterText() } //Main function to load the Minter Cards ---------------------------------------- const loadCards = async (cardIdentifierPrefix, forceSearch = false) => { const loadToken = minterBoardInfiniteState.loadToken + 1 minterBoardInfiniteState.loadToken = loadToken detachMinterBoardInfiniteScroll() minterBoardInfiniteState.cards = [] minterBoardInfiniteState.cursor = 0 minterBoardInfiniteState.inFlight = false minterBoardInfiniteState.complete = false minterBoardInfiniteState.isARBoard = false minterBoardInfiniteState.showExisting = false minterBoardInfiniteState.displayedCount = 0 minterBoardInfiniteState.mintedCount = 0 minterBoardInfiniteState.totalCount = 0 minterBoardInfiniteState.isBackgroundLoading = false minterBoardInfiniteState.counterSpan = null minterBoardInfiniteState.container = null minterBoardInfiniteState.backgroundRunnerToken = 0 minterBoardUpdateState.cardSnapshot.clear() minterBoardUpdateState.commentSnapshot.clear() minterBoardUpdateState.pollSnapshot.clear() minterBoardUpdateState.inviteSnapshot.clear() hideMinterBoardUpdateBanner() minterBoardCardDataByIdentifier.clear() commentCountCache.clear() if (typeof clearMinterBoardInviteStateCaches === "function") { clearMinterBoardInviteStateCaches() } if (forceSearch) { minterBoardCardDataCache.clear() resolvedMinterNameByIdentifierCache.clear() verifyMinterCache.clear() if (typeof clearPollResultsCache === "function") { clearPollResultsCache() } } if ( !cachedMinterGroup || cachedMinterGroup.length === 0 || !cachedMinterAdmins || getEffectiveMinterAdminCount(cachedMinterAdmins) === 0 ) { await initializeCachedGroups() } const cardsContainer = document.getElementById("cards-container") const displayMode = getMinterBoardDisplayMode() cardsContainer.classList.toggle( "cards-container--list", displayMode === "list" ) cardsContainer.classList.toggle( "cards-container--grid", displayMode !== "list" ) cardsContainer.innerHTML = getBoardLoadingHTML("Loading cards...") const counterSpan = document.getElementById("board-card-counter") if (counterSpan) counterSpan.textContent = "(loading...)" const isARBoard = cardIdentifierPrefix.startsWith("QM-AR-card") const showExistingCheckbox = document.getElementById("show-existing-checkbox") const showExisting = showExistingCheckbox && showExistingCheckbox.checked minterBoardInfiniteState.isARBoard = isARBoard minterBoardInfiniteState.showExisting = !!showExisting minterBoardInfiniteState.counterSpan = counterSpan minterBoardInfiniteState.container = cardsContainer let afterTime = 0 let dayRange = 0 const timeRangeSelect = document.getElementById("time-range-select") if (timeRangeSelect) { const days = parseInt(timeRangeSelect.value, 10) dayRange = Number.isNaN(days) ? 0 : days if (dayRange > 0) { const now = Date.now() afterTime = now - dayRange * 24 * 60 * 60 * 1000 } } try { const rawResults = await fetchCachedBoardSearchResources( cardIdentifierPrefix, dayRange, afterTime, forceSearch ) if (loadToken !== minterBoardInfiniteState.loadToken) return if (!rawResults || rawResults.length === 0) { minterBoardInfiniteState.totalCount = 0 minterBoardInfiniteState.isBackgroundLoading = false cardsContainer.innerHTML = "

No cards found.

" if (counterSpan) counterSpan.textContent = "(0 displayed, 0 minters)" return } const validated = ( await Promise.all( rawResults.map(async (r) => (await validateCardStructure(r)) ? r : null ) ) ).filter(Boolean) if (loadToken !== minterBoardInfiniteState.loadToken) return if (validated.length === 0) { minterBoardInfiniteState.totalCount = 0 minterBoardInfiniteState.isBackgroundLoading = false cardsContainer.innerHTML = "

No valid cards found.

" if (counterSpan) counterSpan.textContent = "(0 displayed, 0 minters)" return } let processedCards if (isARBoard) { processedCards = await processARBoardCards(validated) } else { processedCards = await processMinterBoardCards(validated) } let selectedSort = "newest" const sortSelect = document.getElementById("sort-select") if (sortSelect) { selectedSort = sortSelect.value } const isVoteSort = selectedSort === "least-votes" || selectedSort === "most-votes" if (isVoteSort) { // Kakashi Note: Vote sorting needs extra poll fetches, so we show explicit status instead of a silent delay. cardsContainer.innerHTML = getBoardLoadingHTML( "Loading and resorting cards by votes..." ) if (counterSpan) counterSpan.textContent = "(loading and resorting by votes...)" } const getCardTimestamp = (card) => card.updated || card.created || 0 const compareNames = (nameA, nameB) => { const safeA = (nameA || "").trim() const safeB = (nameB || "").trim() return safeA.localeCompare(safeB, undefined, { sensitivity: "base" }) } if (selectedSort === "name" || selectedSort === "nominee-name") { const nomineeNameByCard = new WeakMap() await Promise.all( processedCards.map(async (card) => { const cachedNominee = resolvedMinterNameByIdentifierCache.get( card.identifier ) if (cachedNominee) { nomineeNameByCard.set(card, cachedNominee) return } try { const nomineeName = await extractMinterCardsMinterName( card.identifier ) nomineeNameByCard.set(card, nomineeName || "") } catch (error) { nomineeNameByCard.set(card, card.name || "") } }) ) processedCards.sort((a, b) => { const nomineeA = nomineeNameByCard.get(a) || "" const nomineeB = nomineeNameByCard.get(b) || "" const byNominee = compareNames(nomineeA, nomineeB) if (byNominee !== 0) return byNominee return getCardTimestamp(b) - getCardTimestamp(a) }) } else if (selectedSort === "publisher-name") { processedCards.sort((a, b) => { const byPublisher = compareNames(a.name, b.name) if (byPublisher !== 0) return byPublisher return getCardTimestamp(b) - getCardTimestamp(a) }) } else if (selectedSort === "recent-comments") { // Compute comment timestamps only when this sort is selected. for (const card of processedCards) { card.newestCommentTimestamp = await getNewestCommentTimestamp( card.identifier ) } processedCards.sort( (a, b) => (b.newestCommentTimestamp || 0) - (a.newestCommentTimestamp || 0) ) } else if (selectedSort === "least-votes") { await applyVoteSortingData(processedCards, /* ascending= */ true) } else if (selectedSort === "most-votes") { await applyVoteSortingData(processedCards, /* ascending= */ false) } if (loadToken !== minterBoardInfiniteState.loadToken) return cardsContainer.innerHTML = "" if (displayMode === "list") { cardsContainer.insertAdjacentHTML( "beforeend", getMinterBoardListHeaderHTML() ) } setMinterBoardCardSnapshot(validated) minterBoardInfiniteState.cards = processedCards minterBoardInfiniteState.cursor = 0 minterBoardInfiniteState.complete = false minterBoardInfiniteState.displayedCount = 0 minterBoardInfiniteState.mintedCount = 0 minterBoardInfiniteState.totalCount = processedCards.length minterBoardInfiniteState.isBackgroundLoading = processedCards.length > 0 updateMinterBoardCounterText() startMinterBoardBackgroundRender(loadToken) startMinterBoardBackgroundUpdateChecks() } catch (error) { if (loadToken !== minterBoardInfiniteState.loadToken) return minterBoardInfiniteState.isBackgroundLoading = false console.error("Error loading cards:", error) cardsContainer.innerHTML = "

Failed to load cards.

" if (counterSpan) { counterSpan.textContent = "(error loading)" } } } 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 (minterIdentity) => { try { const normalizedIdentity = String(minterIdentity || "").trim() if (!normalizedIdentity) return false const qortalAddressPattern = /^Q[a-zA-Z0-9]{33}$/ const minterAddress = qortalAddressPattern.test(normalizedIdentity) ? normalizedIdentity : (await getNameInfoCached(normalizedIdentity))?.owner || "" if (!minterAddress) return false const isValid = await getAddressInfo(minterAddress) if (!isValid || typeof isValid !== "object" || !isValid.address) return false // Then check if they're in the minter group // const minterGroup = await fetchMinterGroupMembers() const minterGroup = Array.isArray(cachedMinterGroup) ? cachedMinterGroup : [] // const adminGroup = await fetchMinterGroupAdmins() const adminGroup = Array.isArray(cachedMinterAdmins) ? cachedMinterAdmins : [] const minterGroupAddresses = minterGroup.map((m) => String(m?.member || "").trim()) const adminGroupAddresses = adminGroup.map((m) => String(m?.member || "").trim()) return ( minterGroupAddresses.includes(minterAddress) || adminGroupAddresses.includes(minterAddress) ) } catch (err) { console.warn("verifyMinter error:", err) return false } } const applyVoteSortingData = async (cards, ascending = true) => { // const minterGroupMembers = await fetchMinterGroupMembers() const minterGroupMembers = cachedMinterGroup // const minterAdmins = await fetchMinterGroupAdmins() const minterAdmins = cachedMinterAdmins for (const card of cards) { try { const cardDataResponse = await fetchMinterBoardCardDataCached(card) if (!cardDataResponse || !cardDataResponse.poll) { card._adminVotes = 0 card._adminYes = 0 card._minterVotes = 0 card._minterYes = 0 continue } const pollResults = await fetchPollResultsCached(cardDataResponse.poll) const { adminYes, adminNo, minterYes, minterNo } = await processPollData( pollResults, minterGroupMembers, minterAdmins, getCardNomineeName(cardDataResponse), card.identifier, { includeDetails: false } ) card._adminVotes = adminYes + adminNo card._adminYes = adminYes card._minterVotes = minterYes + minterNo card._minterYes = minterYes } catch (error) { console.warn( `Error fetching or processing poll for card ${card.identifier}:`, error ) card._adminVotes = 0 card._adminYes = 0 card._minterVotes = 0 card._minterYes = 0 } } if (ascending) { // least votes first cards.sort((a, b) => { const diffAdminTotal = a._adminVotes - b._adminVotes if (diffAdminTotal !== 0) return diffAdminTotal const diffAdminYes = a._adminYes - b._adminYes if (diffAdminYes !== 0) return diffAdminYes const diffMinterTotal = a._minterVotes - b._minterVotes if (diffMinterTotal !== 0) return diffMinterTotal return a._minterYes - b._minterYes }) } else { // most votes first cards.sort((a, b) => { const diffAdminTotal = b._adminVotes - a._adminVotes if (diffAdminTotal !== 0) return diffAdminTotal const diffAdminYes = b._adminYes - a._adminYes if (diffAdminYes !== 0) return diffAdminYes const diffMinterTotal = b._minterVotes - a._minterVotes if (diffMinterTotal !== 0) return diffMinterTotal return b._minterYes - a._minterYes }) } } const removeSkeleton = (cardIdentifier) => { const skeletonCard = document.getElementById(`skeleton-${cardIdentifier}`) if (skeletonCard) { skeletonCard.remove() } } const replaceSkeleton = (cardIdentifier, htmlContent) => { const skeletonCard = document.getElementById(`skeleton-${cardIdentifier}`) if (skeletonCard) { skeletonCard.outerHTML = htmlContent } } const createSkeletonCardHTML = (cardIdentifier) => { if (getMinterBoardDisplayMode() === "list") { return `
` } return `

LOADING CARD...

PLEASE BE PATIENT

While data loads from QDN...

` } const getMinterBoardListHeaderHTML = () => ` ` const resolveNomineeIdentity = async (rawNomineeInput) => { // Kakashi Note: Nominee must resolve to a registered name so duplicate checks and moderation stay identity-safe. const nomineeInput = (rawNomineeInput || "").trim() if (!nomineeInput) { return { error: "Nominee name or address is required." } } const directNameInfo = await getNameInfoCached(nomineeInput) if (directNameInfo && directNameInfo.owner) { return { nomineeName: directNameInfo.name || nomineeInput, nomineeAddress: directNameInfo.owner, } } const nameFromAddress = await getNameFromAddress(nomineeInput) if (nameFromAddress && nameFromAddress !== nomineeInput) { const resolvedNameInfo = await getNameInfoCached(nameFromAddress) if (resolvedNameInfo && resolvedNameInfo.owner) { return { nomineeName: resolvedNameInfo.name || nameFromAddress, nomineeAddress: resolvedNameInfo.owner, } } } return { error: "Nominee must have a registered Qortal name. Enter a valid name, or an address that has a registered name.", } } // Function to find existing nomination cards for a nominee ---------------------------------------- const fetchExistingCardsByNominee = async ( cardIdentifierPrefix, nomineeName ) => { try { const response = await searchSimple( "BLOG_POST", `${cardIdentifierPrefix}`, "", 0, 0, "", true ) if (!response || !Array.isArray(response) || response.length === 0) { return [] } 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) { return [] } // Kakashi Note: Duplicate nomination checks are keyed by nominee identity, not by the publishing account. const normalizedNominee = nomineeName.toLowerCase() const tasks = validCards.map((card) => { return async () => { try { const cardDataResponse = await qortalRequest({ action: "FETCH_QDN_RESOURCE", name: card.name, service: "BLOG_POST", identifier: card.identifier, }) const candidateName = getCardNomineeName(cardDataResponse).toLowerCase() if (candidateName !== normalizedNominee) { return null } return { resource: card, cardDataResponse, } } catch (error) { console.warn( `Failed to read card ${card.identifier} for nominee matching`, error ) return null } } }) const matches = (await runWithConcurrency(tasks, 10)) .filter(Boolean) .sort((a, b) => { const aTime = a.resource.updated || a.resource.created || 0 const bTime = b.resource.updated || b.resource.created || 0 return bTime - aTime }) return matches } catch (error) { console.error("Error fetching existing nominee cards:", error) return [] } } // Validate that a card is indeed a card and not a comment. ------------------------------------- const validateCardStructure = async (card) => { return ( typeof card === "object" && card.name && card.service === "BLOG_POST" && card.identifier && !card.identifier.includes("comment") && card.created ) } // Load existing card data passed, into the form for editing ------------------------------------- const loadCardIntoForm = async (cardData) => { console.log("Loading existing card data:", cardData) document.getElementById("nominee-name-input").value = getCardNomineeName( cardData, getCardNomineeAddress(cardData) ) document.getElementById("card-header").value = cardData.header if (typeof ensureBoardRichTextEditor === "function") { ensureBoardRichTextEditor( minterBoardPublishEditorKey, "Share why this nominee should be considered for minting privileges." ) setBoardRichTextEditorHtml(minterBoardPublishEditorKey, cardData.content) } else { const contentField = document.getElementById("card-content") if (contentField) { contentField.value = cardData.content } } const linksContainer = document.getElementById("links-container") linksContainer.innerHTML = "" ;(cardData.links || []).forEach((link) => { const linkInput = document.createElement("input") linkInput.type = "text" linkInput.className = "card-link" linkInput.value = link linksContainer.appendChild(linkInput) }) if ((cardData.links || []).length === 0) { const linkInput = document.createElement("input") linkInput.type = "text" linkInput.className = "card-link" linkInput.placeholder = "Enter QDN link" linksContainer.appendChild(linkInput) } } const openMinterBoardCardEditor = async (cardIdentifier) => { const cardData = minterBoardCardDataByIdentifier.get(cardIdentifier) if (!cardData) { alert("Unable to load this card for editing right now.") return } isExistingCard = true existingCardIdentifier = cardIdentifier existingCardData = cardData const publishForm = document.getElementById("publish-card-form") if (publishForm) { publishForm.reset() } const linksContainer = document.getElementById("links-container") if (linksContainer) { linksContainer.innerHTML = "" } const publishCardView = document.getElementById("publish-card-view") const cardsContainer = document.getElementById("cards-container") if (cardsContainer) { cardsContainer.style.display = "none" } if (publishCardView) { publishCardView.style.display = "flex" } await loadCardIntoForm(cardData) const submitButton = document.getElementById("submit-publish-button") if (submitButton) { submitButton.textContent = "UPDATE NOMINATION" } if (publishCardView?.scrollIntoView) { publishCardView.scrollIntoView({ behavior: "smooth", block: "start" }) } } // Main function to publish a new Minter Card ----------------------------------------------- const publishCard = async (cardIdentifierPrefix) => { if (minterBoardPublishInProgress) { return } if (!Array.isArray(cachedMinterGroup) || !Array.isArray(cachedMinterAdmins)) { await initializeCachedGroups() } const minterGroupData = cachedMinterGroup const minterAdminData = cachedMinterAdmins const minterGroupAddresses = minterGroupData.map((m) => m.member) const minterAdminAddresses = minterAdminData.map((m) => m.member) const userAddress = userState.accountAddress const userName = userState.accountName const canPublishNomination = minterGroupAddresses.includes(userAddress) || minterAdminAddresses.includes(userAddress) // Kakashi Note: Nomination-only policy requires MINTER membership/admin role plus level 5+ before publishing. if (!canPublishNomination) { alert("You have to be a level 5 or above Minter to nominate a user") return } const nomineeInput = document .getElementById("nominee-name-input") .value.trim() const header = document.getElementById("card-header").value.trim() const contentText = typeof getBoardRichTextEditorText === "function" ? getBoardRichTextEditorText(minterBoardPublishEditorKey) : document.getElementById("card-content")?.value?.trim() || "" const content = typeof getBoardRichTextEditorHtml === "function" ? getBoardRichTextEditorHtml(minterBoardPublishEditorKey) : qRenderRichContentHtml(contentText) const links = Array.from(document.querySelectorAll(".card-link")) .map((input) => input.value.trim()) .filter((link) => link.startsWith("qortal://")) const submitButton = document.getElementById("submit-publish-button") if (!header || !content) { alert("Header and content are required!") return } const publishSteps = [ { key: "access", label: "Checking publishing access", detail: "Verifying Minter/Admin membership and level 5+.", status: "active", }, { key: "identity", label: "Resolving nominee identity", detail: "Looking up the nominee name or address.", status: "pending", }, { key: "duplicate", label: "Checking for duplicates", detail: "Confirming whether this is a new nomination or an update.", status: "pending", }, { key: "package", label: "Preparing the payload", detail: "Serializing the nomination data for QDN.", status: "pending", }, { key: "publish", label: "Publishing to QDN", detail: "Submitting the card and waiting for the network response.", status: "pending", }, { key: "poll", label: "Creating or reusing the poll", detail: "Making sure the nomination poll is in place.", status: "pending", }, { key: "refresh", label: "Refreshing the board", detail: "Reloading cards so the latest state appears.", status: "pending", }, ] let publishProgress = { title: "Preparing nomination", subtitle: "Please keep this window open while the nomination is validated and published.", message: "The publish path can take a little while because we verify identity, check for duplicates, and wait for QDN to accept the card.", steps: publishSteps, } const syncPublishProgress = () => { if ( typeof updateBoardPublishProgressModal === "function" && publishProgress ) { updateBoardPublishProgressModal(publishProgress) } } const setPublishStep = (stepKey, status, detail = null) => { publishProgress.steps = setBoardPublishProgressStepStatus( publishProgress.steps, stepKey, status, detail ) syncPublishProgress() } const closePublishProgress = () => { if (typeof closeBoardPublishProgressModal === "function") { closeBoardPublishProgressModal() } } try { if (typeof showBoardPublishProgressModal === "function") { showBoardPublishProgressModal(publishProgress) } minterBoardPublishInProgress = true if (submitButton) { submitButton.disabled = true submitButton.textContent = "PUBLISHING..." } setPublishStep("access", "active") let userAddressInfo try { userAddressInfo = await getAddressInfo(userAddress) } catch (error) { console.error( "Unable to fetch current user address info for level check:", error ) setPublishStep( "access", "error", "Unable to verify the current account level right now." ) await qBoardDelay(1400) closePublishProgress() alert("Unable to verify your minter level right now. Please try again.") return } const userLevel = Number(userAddressInfo?.level || 0) if (userLevel < 5) { setPublishStep( "access", "error", "Publishing requires a level 5 or above Minter account." ) await qBoardDelay(1400) closePublishProgress() // Kakashi Note: Reuse the same denial copy for non-level-5 users so policy messaging stays consistent. alert("You have to be a level 5 or above Minter to nominate a user") return } setPublishStep("access", "done") setPublishStep("identity", "active") const nomineeResolution = await resolveNomineeIdentity(nomineeInput) if (nomineeResolution.error) { setPublishStep("identity", "error", nomineeResolution.error) await qBoardDelay(1400) closePublishProgress() alert(nomineeResolution.error) return } const { nomineeName, nomineeAddress } = nomineeResolution const normalizedNomineeName = (nomineeName || "").toLowerCase() const normalizedUserName = (userName || "").toLowerCase() // Kakashi Note: Self-nominations are blocked to enforce peer nomination and reduce self-published spam. if ( normalizedNomineeName === normalizedUserName || nomineeAddress === userAddress ) { setPublishStep( "identity", "error", "Self-nominations are disabled. Please nominate another user." ) await qBoardDelay(1400) closePublishProgress() alert("Self-nominations are disabled. Please nominate another user.") return } const nomineeAlreadyMinter = await verifyMinterCached(nomineeName) if (nomineeAlreadyMinter) { setPublishStep( "identity", "error", `${nomineeName} is already a minter/admin. Nomination card not needed.` ) await qBoardDelay(1400) closePublishProgress() alert( `${nomineeName} is already a minter/admin. Nomination card not needed.` ) return } setPublishStep("identity", "done") setPublishStep("duplicate", "active") const nomineeMatches = await fetchExistingCardsByNominee( cardIdentifierPrefix, nomineeName ) const samePublisherMatches = nomineeMatches.filter( (m) => m.resource.name === userName ) const otherPublisherMatches = nomineeMatches.filter( (m) => m.resource.name !== userName ) // Kakashi Note: Same publisher can update their nomination; different publisher for same nominee is blocked as duplicate. if (otherPublisherMatches.length > 0) { const existingPublisher = otherPublisherMatches[0].resource.name setPublishStep( "duplicate", "error", `A nomination card for ${nomineeName} already exists (published by ${existingPublisher}).` ) await qBoardDelay(1400) closePublishProgress() alert( `A nomination card for ${nomineeName} already exists (published by ${existingPublisher}). Duplicate nominations are blocked.` ) return } if (samePublisherMatches.length > 0) { const latestMatch = samePublisherMatches[0] isExistingCard = true existingCardIdentifier = latestMatch.resource.identifier existingCardData = latestMatch.cardDataResponse || {} } else { isExistingCard = false existingCardIdentifier = "" existingCardData = {} } if ( isExistingCard && (!existingCardData || Object.keys(existingCardData).length === 0) ) { setPublishStep( "duplicate", "error", "Unable to load your existing nomination card for update." ) await qBoardDelay(1400) closePublishProgress() alert( "Unable to load your existing nomination card for update. Please refresh and try again." ) return } publishProgress.title = isExistingCard ? "Updating nomination" : "Publishing nomination" syncPublishProgress() setPublishStep("duplicate", "done") setPublishStep("package", "active") const cardIdentifier = isExistingCard && existingCardIdentifier ? existingCardIdentifier : `${cardIdentifierPrefix}-${await uid()}` let existingPollName if (existingCardData && existingCardData.poll) { existingPollName = existingCardData.poll } const pollName = existingPollName || `${cardIdentifier}-poll` const pollDescription = `Mintership Board Poll for ${nomineeName} (published by ${userName})` // Kakashi Note: Keep nominee and publisher fields separate for accountability and correct downstream display logic. const cardData = { header, content, links, nominee: nomineeName, nomineeAddress, nominator: userName, nominatorAddress: userAddress, creator: nomineeName, creatorAddress: nomineeAddress, publishedBy: userName, publishedByAddress: userAddress, timestamp: Date.now(), poll: pollName, // either the existing poll or a new one } let base64CardData = await objectToBase64(cardData) if (!base64CardData) { console.log( `initial base64 object creation with objectToBase64 failed, using btoa...` ) base64CardData = btoa(JSON.stringify(cardData)) } setPublishStep("package", "done") setPublishStep("publish", "active") await qortalRequest({ action: "PUBLISH_QDN_RESOURCE", name: userName, service: "BLOG_POST", identifier: cardIdentifier, data64: base64CardData, }) setPublishStep("publish", "done") if (!isExistingCard || !existingPollName) { setPublishStep( "poll", "active", isExistingCard ? "The existing poll was missing, so a new one is being created." : "Creating the nomination poll for the new card." ) await qortalRequest({ action: "CREATE_POLL", pollName, pollDescription, pollOptions: ["Yes, No"], pollOwnerAddress: userAddress, }) setPublishStep("poll", "done") } else { setPublishStep("poll", "done", "Existing poll retained.") } const wasExistingCard = isExistingCard const hadExistingPollName = Boolean(existingPollName) rememberOptimisticMinterBoardCard( cardIdentifierPrefix, userName, cardIdentifier, cardData, cardData.timestamp ) isExistingCard = false existingCardData = {} existingCardIdentifier = "" document.getElementById("publish-card-form").reset() if (typeof clearBoardRichTextEditor === "function") { clearBoardRichTextEditor(minterBoardPublishEditorKey) } document.getElementById("publish-card-view").style.display = "none" document.getElementById("cards-container").style.display = "flex" setPublishStep("refresh", "active") await loadCards(minterCardIdentifierPrefix, true) setPublishStep("refresh", "done") await qBoardDelay(250) closePublishProgress() if (!hadExistingPollName && !wasExistingCard) { alert(`Nomination card for ${nomineeName} published successfully!`) } else if (!hadExistingPollName) { alert( `Nomination card for ${nomineeName} updated, and a new poll was created (existing poll missing).` ) } else { alert(`Nomination card for ${nomineeName} updated successfully!`) } } catch (error) { console.error("Error publishing card or poll:", error) if (publishProgress) { publishProgress.message = "The publish request failed before completion. Please try again." publishProgress.steps = setBoardPublishProgressStepStatus( publishProgress.steps, "publish", "error", error?.message || "Publish failed." ) syncPublishProgress() await qBoardDelay(1400) } if (typeof closeBoardPublishProgressModal === "function") { closeBoardPublishProgressModal() } alert("Failed to publish card and poll.") } finally { minterBoardPublishInProgress = false if (submitButton) { submitButton.disabled = false submitButton.textContent = isExistingCard ? "UPDATE NOMINATION" : "PUBLISH" } } } let globalVoterMap = new Map() const processPollData = async ( pollData, minterGroupMembers, minterAdmins, nomineeName, cardIdentifier, options = {} ) => { const includeDetails = options?.includeDetails === true if ( !pollData || !Array.isArray(pollData.voteWeights) || !Array.isArray(pollData.votes) ) { console.warn("Poll data is missing or invalid. pollData:", pollData) return { adminYes: 0, adminNo: 0, minterYes: 0, minterNo: 0, totalYes: 0, totalNo: 0, totalYesWeight: 0, totalNoWeight: 0, detailsHtml: `

Poll data is invalid or missing.

`, userVote: null, } } const memberAddresses = ( Array.isArray(minterGroupMembers) ? minterGroupMembers : [] ).map((m) => m.member) const minterAdminAddresses = ( Array.isArray(minterAdmins) ? minterAdmins : [] ).map((m) => m.member) const featureTriggerPassed = await featureTriggerCheck() let adminAddresses = [...minterAdminAddresses] if (!featureTriggerPassed) { console.log( `featureTrigger is NOT passed, only showing admin results from Minter Admins and Group Admins` ) const adminGroupsMembers = await fetchAllAdminGroupsMembers().catch( () => [] ) const groupAdminAddresses = adminGroupsMembers.map((m) => m.member) adminAddresses = [...minterAdminAddresses, ...groupAdminAddresses] } let adminYes = 0, adminNo = 0 let minterYes = 0, minterNo = 0 let yesWeight = 0, noWeight = 0 let userVote = null for (const w of pollData.voteWeights) { if (w.optionName.toLowerCase() === "yes") { yesWeight = w.voteWeight } else if (w.optionName.toLowerCase() === "no") { noWeight = w.voteWeight } } const voterPromises = pollData.votes.map(async (vote) => { const optionIndex = vote.optionIndex // 0 => yes, 1 => no const voterPublicKey = vote.voterPublicKey const voterAddress = await getAddressFromPublicKey(voterPublicKey) if (voterAddress === userState.accountAddress) { userVote = optionIndex } if (optionIndex === 0) { if (adminAddresses.includes(voterAddress)) { adminYes++ } else if (memberAddresses.includes(voterAddress)) { minterYes++ } else { console.log( `voter ${voterAddress} is not a minter nor an admin... Not included in aggregates.` ) } } else if (optionIndex === 1) { if (adminAddresses.includes(voterAddress)) { adminNo++ } else if (memberAddresses.includes(voterAddress)) { minterNo++ } else { console.log( `voter ${voterAddress} is not a minter nor an admin... Not included in aggregates.` ) } } const isAdmin = adminAddresses.includes(voterAddress) const isMinter = memberAddresses.includes(voterAddress) let voterName = "" let blocksMinted = 0 if (includeDetails) { const [nameInfo, addressInfo] = await Promise.all([ getNameFromAddress(voterAddress).catch((err) => { console.warn(`No name for address ${voterAddress}`, err) return "" }), getAddressInfo(voterAddress).catch((e) => { console.warn(`Failed to get addressInfo for ${voterAddress}`, e) return null }), ]) voterName = nameInfo && nameInfo !== voterAddress ? nameInfo : "" blocksMinted = addressInfo?.blocksMinted || 0 } return { optionIndex, voterPublicKey, voterAddress, voterName, isAdmin, isMinter, blocksMinted, } }) const allVoters = await Promise.all(voterPromises) const yesVoters = [] const noVoters = [] let totalMinterAndAdminYesWeight = Number(yesWeight || 0) let totalMinterAndAdminNoWeight = Number(noWeight || 0) for (const v of allVoters) { if (v.optionIndex === 0) { yesVoters.push(v) if (includeDetails) { totalMinterAndAdminYesWeight += v.blocksMinted } } else if (v.optionIndex === 1) { noVoters.push(v) if (includeDetails) { totalMinterAndAdminNoWeight += v.blocksMinted } } } if (includeDetails) { totalMinterAndAdminYesWeight = 0 totalMinterAndAdminNoWeight = 0 for (const v of yesVoters) { totalMinterAndAdminYesWeight += v.blocksMinted } for (const v of noVoters) { totalMinterAndAdminNoWeight += v.blocksMinted } } yesVoters.sort((a, b) => b.blocksMinted - a.blocksMinted) noVoters.sort((a, b) => b.blocksMinted - a.blocksMinted) const sortedAllVoters = allVoters.sort( (a, b) => b.blocksMinted - a.blocksMinted ) await createVoterMap(sortedAllVoters, cardIdentifier) const safeNominee = qEscapeHtml(nomineeName) const detailsHtml = includeDetails ? `

${safeNominee}'s

Support Poll Result Details

Yes Vote Details

${buildVotersTableHtml(yesVoters, /* tableColor= */ "green")}

No Vote Details

${buildVotersTableHtml(noVoters, /* tableColor= */ "red")}
` : `

Poll details will load when opened.

` const totalYes = adminYes + minterYes const totalNo = adminNo + minterNo return { adminYes, adminNo, minterYes, minterNo, totalYes, totalNo, totalYesWeight: totalMinterAndAdminYesWeight, totalNoWeight: totalMinterAndAdminNoWeight, detailsHtml, userVote, } } const createVoterMap = async (voters, cardIdentifier) => { const voterMap = new Map() voters.forEach((voter) => { const voterEntry = { vote: voter.optionIndex === 0 ? "yes" : "no", // Use optionIndex directly voterType: voter.isAdmin ? "Admin" : voter.isMinter ? "Minter" : "User", blocksMinted: voter.blocksMinted, } const registerIdentity = (identity) => { const normalizedIdentity = String(identity || "").trim() if (!normalizedIdentity) return voterMap.set(normalizedIdentity, voterEntry) voterMap.set(normalizedIdentity.toLowerCase(), voterEntry) } registerIdentity(voter.voterName) registerIdentity(voter.voterAddress) }) globalVoterMap.set(cardIdentifier, voterMap) } const buildVotersTableHtml = (voters, tableColor) => { if (!voters.length) { return `

No voters here.

` } // Decide extremely dark background for the let bodyBackground if (tableColor === "green") { bodyBackground = "rgba(0, 18, 0, 0.8)" // near-black green } else if (tableColor === "red") { bodyBackground = "rgba(30, 0, 0, 0.8)" // near-black red } else { // fallback color if needed bodyBackground = "rgba(40, 20, 10, 0.8)" } // tableColor is used for the , bodyBackground for the const minterColor = "rgb(98, 122, 167)" const adminColor = "rgb(44, 209, 151)" const userColor = "rgb(102, 102, 102)" return ` ${voters .map((v) => { const userType = v.isAdmin ? "Admin" : v.isMinter ? "Minter" : "User" const pollName = v.pollName const displayName = v.voterName ? v.voterName : v.voterAddress const safeDisplayName = qEscapeHtml(displayName) return ` ` }) .join("")}
Voter Name/Address Voter Type Voter Weight(=BlocksMinted)
${safeDisplayName} ${userType} ${v.blocksMinted}
` } // Post a comment on a card. --------------------------------- const postComment = async (cardIdentifier) => { const editingState = typeof boardCommentEditState !== "undefined" ? boardCommentEditState : { cardIdentifier: "", commentIdentifier: "", isEditing: false } const replyState = typeof boardCommentReplyState !== "undefined" ? boardCommentReplyState : { cardIdentifier: "", commentIdentifier: "", publisherName: "", timestamp: "", timestampText: "", contentHtml: "", isReplying: false, } const commentText = typeof getBoardCommentEditorText === "function" ? getBoardCommentEditorText(cardIdentifier) : "" const fallbackCommentInput = document.getElementById( `new-comment-${cardIdentifier}` ) const combinedCommentText = commentText || fallbackCommentInput?.value?.trim() || "" if (!combinedCommentText) { alert("Comment cannot be empty!") return } try { //Ensure the user is not on the blockList prior to allowing them to publish a comment. const blockedNames = await fetchBlockList() if (blockedNames.includes(userState.accountName)) { alert("You are on the block list and cannot publish comments.") return } const commentHtml = (typeof getBoardCommentEditorHtml === "function" ? getBoardCommentEditorHtml(cardIdentifier) : "") || qRenderBoardCommentHtml(combinedCommentText) const existingCommentData = editingState.isEditing && editingState.cardIdentifier === cardIdentifier && editingState.commentIdentifier && typeof getBoardCommentData === "function" ? getBoardCommentData(editingState.commentIdentifier) : null const isReplyingToThisComment = !editingState.isEditing && replyState.isReplying && replyState.cardIdentifier === cardIdentifier && replyState.commentIdentifier const replyTo = isReplyingToThisComment ? { identifier: replyState.commentIdentifier, creator: replyState.publisherName || "", timestamp: replyState.timestamp || Date.now(), timestampText: replyState.timestampText || "", content: replyState.contentHtml || "", } : null const commentData = { content: commentHtml, creator: userState.accountName, timestamp: Date.now(), ...(existingCommentData?.replyTo ? { replyTo: existingCommentData.replyTo } : {}), ...(!editingState.isEditing && replyTo ? { replyTo } : {}), } const isEditingThisComment = editingState.isEditing && editingState.cardIdentifier === cardIdentifier && editingState.commentIdentifier const uniqueCommentIdentifier = isEditingThisComment ? editingState.commentIdentifier : `comment-${cardIdentifier}-${await uid()}` let base64CommentData = await objectToBase64(commentData) if (!base64CommentData) { base64CommentData = btoa(JSON.stringify(commentData)) } await qortalRequest({ action: "PUBLISH_QDN_RESOURCE", name: userState.accountName, service: "BLOG_POST", identifier: uniqueCommentIdentifier, data64: base64CommentData, }) rememberOptimisticMinterBoardComment( cardIdentifier, userState.accountName, uniqueCommentIdentifier, commentData, commentData.timestamp ) if (typeof clearBoardCommentEditState === "function") { await clearBoardCommentEditState(cardIdentifier) } else if (typeof clearBoardCommentEditor === "function") { clearBoardCommentEditor(cardIdentifier) } if (fallbackCommentInput) { fallbackCommentInput.value = "" } if (!isEditingThisComment) { updateDisplayedCommentCount(cardIdentifier, 1) void notifyMinterBoardEvent({ eventType: replyTo ? "reply" : "comment", cardIdentifier, commentIdentifier: uniqueCommentIdentifier, actionIdentifier: uniqueCommentIdentifier, actorAddress: userState.accountAddress || "", replyTo, summary: replyTo ? `${userState.accountName || "A user"} replied to a comment.` : `${userState.accountName || "A user"} posted a comment.`, }) } const commentsSection = document.getElementById( `comments-section-${cardIdentifier}` ) if (commentsSection && commentsSection.style.display === "block") { await displayComments(cardIdentifier) if ( isEditingThisComment && typeof scrollBoardCommentIntoView === "function" ) { await scrollBoardCommentIntoView( cardIdentifier, uniqueCommentIdentifier ) } else if (typeof scrollBoardCommentsToBottom === "function") { await scrollBoardCommentsToBottom(cardIdentifier) } const commentButton = document.getElementById( `comment-button-${cardIdentifier}` ) if (commentButton) { commentButton.textContent = "HIDE COMMENTS" } } } catch (error) { console.error("Error posting comment:", error) alert("Failed to post comment. Error: " + error) } } const updateDisplayedCommentCount = (cardIdentifier, delta = 0) => { const commentButton = document.getElementById( `comment-button-${cardIdentifier}` ) const listCommentCount = document.getElementById( `list-comment-count-${cardIdentifier}` ) const currentCount = Number( commentButton?.dataset?.commentCount || listCommentCount?.dataset?.commentCount || commentCountCache.get(cardIdentifier) || 0 ) const nextCount = Math.max(0, currentCount + delta) commentCountCache.set(cardIdentifier, nextCount) if (commentButton) { commentButton.dataset.commentCount = String(nextCount) if ( commentButton.textContent !== "HIDE COMMENTS" && commentButton.textContent !== "LOADING..." ) { commentButton.textContent = `COMMENTS (${nextCount})` } } if (listCommentCount) { listCommentCount.dataset.commentCount = String(nextCount) listCommentCount.textContent = `${nextCount} comment${ nextCount === 1 ? "" : "s" }` } } //Fetch the comments for a card with passed card identifier ---------------------------- const fetchCommentsForCard = async (cardIdentifier) => { try { const response = await searchSimple( "BLOG_POST", `comment-${cardIdentifier}`, "", 0, 0, "", "false" ) const fetchedComments = Array.isArray(response) ? response : [] const existingResourcesByIdentity = new Map( fetchedComments.map((comment) => [ getBoardResourceIdentityKey(comment), comment, ]) ) const optimisticComments = getOptimisticMinterBoardComments( cardIdentifier, existingResourcesByIdentity ) const mergedComments = [...optimisticComments, ...fetchedComments].sort( (a, b) => getBoardResourceTimestamp(a) - getBoardResourceTimestamp(b) ) rememberMinterBoardCommentSnapshot(cardIdentifier, mergedComments) return mergedComments } catch (error) { console.error(`Error fetching comments for ${cardIdentifier}:`, error) const optimisticComments = getOptimisticMinterBoardComments(cardIdentifier) rememberMinterBoardCommentSnapshot(cardIdentifier, optimisticComments) return optimisticComments } } const displayComments = async (cardIdentifier) => { try { const comments = await fetchCommentsForCard(cardIdentifier) const commentsContainer = document.getElementById( `comments-container-${cardIdentifier}` ) commentsContainer.innerHTML = "" const blockedNames = await fetchBlockList() console.log("Loaded block list:", blockedNames) const voterMap = globalVoterMap.get(cardIdentifier) || new Map() const commentHTMLArray = await Promise.all( comments.map(async (comment) => { try { const commentDataResponse = await fetchMinterBoardCommentData(comment) if (!commentDataResponse || !commentDataResponse.creator) { return null } const commenterName = commentDataResponse.creator if (blockedNames.includes(commenterName)) { console.warn(`Skipping blocked commenter: ${commenterName}`) return null } const commenterLevel = typeof getBoardAccountLevel === "function" ? await getBoardAccountLevel(commenterName) : null const voterInfo = typeof resolveBoardCommentVoterInfo === "function" ? await resolveBoardCommentVoterInfo(commenterName, voterMap) : voterMap.get(commenterName) const commentClasses = ["comment"] const commentStyles = [] let adminBadge = "" const levelBadgeHtml = commenterLevel !== null && typeof commenterLevel !== "undefined" ? `L${qEscapeHtml(String(commenterLevel))}` : "" if (voterInfo) { commentClasses.push("comment--voted") if (voterInfo.voterType === "Admin") { commentClasses.push("comment--vote-admin") const accentColor = voterInfo.vote === "yes" ? "rgba(92, 196, 130, 0.95)" : "rgba(221, 107, 107, 0.95)" const accentSoft = voterInfo.vote === "yes" ? "rgba(92, 196, 130, 0.2)" : "rgba(221, 107, 107, 0.2)" commentClasses.push( voterInfo.vote === "yes" ? "comment--vote-yes" : "comment--vote-no" ) commentStyles.push(`--comment-accent: ${accentColor}`) commentStyles.push(`--comment-accent-soft: ${accentSoft}`) adminBadge = `Admin` } else { commentClasses.push("comment--vote-minter") const accentColor = voterInfo.vote === "yes" ? "rgba(92, 196, 130, 0.55)" : "rgba(221, 107, 107, 0.55)" const accentSoft = voterInfo.vote === "yes" ? "rgba(92, 196, 130, 0.12)" : "rgba(221, 107, 107, 0.12)" commentClasses.push( voterInfo.vote === "yes" ? "comment--vote-yes" : "comment--vote-no" ) commentStyles.push(`--comment-accent: ${accentColor}`) commentStyles.push(`--comment-accent-soft: ${accentSoft}`) } } const timestamp = new Date( commentDataResponse.timestamp ).toLocaleString() const safeCommenterName = qEscapeHtml(commenterName) const commenterNameHtml = typeof buildBoardAccountTriggerHtml === "function" ? buildBoardAccountTriggerHtml({ name: commenterName, label: commenterName, className: "comment-author-name-link", tagName: "button", }) : `${safeCommenterName}` if (typeof rememberBoardCommentData === "function") { rememberBoardCommentData(comment.identifier, commentDataResponse) } else if (typeof rememberBoardCommentContent === "function") { rememberBoardCommentContent( comment.identifier, commentDataResponse.content || "" ) } const canEditComment = typeof canCurrentUserEditPublishedComment === "function" ? await canCurrentUserEditPublishedComment(commenterName) : false const replyButtonHtml = typeof buildBoardCommentReplyButtonHtml === "function" ? buildBoardCommentReplyButtonHtml({ cardIdentifier, commentIdentifier: comment.identifier, publisherName: commenterName, }) : "" const editButtonHtml = canEditComment && typeof buildBoardCommentEditButtonHtml === "function" ? buildBoardCommentEditButtonHtml({ cardIdentifier, commentIdentifier: comment.identifier, publisherName: commenterName, }) : "" const renderedCommentContent = qRenderBoardCommentHtml( commentDataResponse.content ) const replyPreviewHtml = commentDataResponse.replyTo && typeof buildBoardCommentReplyPreviewHtml === "function" ? buildBoardCommentReplyPreviewHtml(commentDataResponse.replyTo, { variant: "embedded", }) : "" const safeTimestamp = qEscapeHtml(timestamp) const optimisticNotice = commentDataResponse._optimisticPending ? `

Published locally. Waiting for QDN indexing.

` : "" const commentStyleAttr = commentStyles.length ? ` style="${commentStyles.join("; ")}"` : "" return `

${commenterNameHtml} ${levelBadgeHtml} ${adminBadge}

${replyButtonHtml} ${editButtonHtml}
${replyPreviewHtml}
${renderedCommentContent}

${safeTimestamp}

${optimisticNotice}
` } catch (err) { console.error(`Error with comment ${comment.identifier}:`, err) return null } }) ) commentHTMLArray .filter((html) => html !== null) .forEach((commentHTML) => { commentsContainer.insertAdjacentHTML("beforeend", commentHTML) }) } catch (err) { console.error(`Error displaying comments for ${cardIdentifier}:`, err) } } // Toggle comments from being shown or not, with passed cardIdentifier for comments being toggled -------------------- const toggleComments = async (cardIdentifier) => { const commentsSection = document.getElementById( `comments-section-${cardIdentifier}` ) const commentButton = document.getElementById( `comment-button-${cardIdentifier}` ) if (!commentsSection || !commentButton) return const count = commentButton.dataset.commentCount const isHidden = commentsSection.style.display === "none" || !commentsSection.style.display if (isHidden) { // Show comments commentButton.textContent = "LOADING..." commentsSection.style.display = "block" if (typeof ensureBoardCommentEditor === "function") { ensureBoardCommentEditor(cardIdentifier, "Write a comment...") } await displayComments(cardIdentifier) // Change the button text to 'HIDE COMMENTS' commentButton.textContent = "HIDE COMMENTS" } else { // Hide comments commentsSection.style.display = "none" commentButton.textContent = `COMMENTS (${count})` } } const setMinterListCommentsVisibility = async ( cardIdentifier, shouldShowComments ) => { const commentsSection = document.getElementById( `comments-section-${cardIdentifier}` ) const commentButton = document.getElementById( `comment-button-${cardIdentifier}` ) if (!commentsSection || !commentButton) return const isHidden = commentsSection.style.display === "none" || !commentsSection.style.display if (shouldShowComments) { if (isHidden) { await toggleComments(cardIdentifier) } return } if (!isHidden) { commentsSection.style.display = "none" const count = commentButton.dataset.commentCount || "0" if ( commentButton.textContent !== "HIDE COMMENTS" && commentButton.textContent !== "LOADING..." ) { commentButton.textContent = `COMMENTS (${count})` } } } const commentCountCache = new Map() const countCommentsCached = async ( cardIdentifier, loadToken = minterBoardInfiniteState.loadToken ) => { if (commentCountCache.has(cardIdentifier)) { return commentCountCache.get(cardIdentifier) } const count = await countComments(cardIdentifier, loadToken) if (loadToken === minterBoardInfiniteState.loadToken) { commentCountCache.set(cardIdentifier, count) } return count } const hydrateMinterBoardCommentCount = async ( cardIdentifier, loadToken = minterBoardInfiniteState.loadToken ) => { if (loadToken !== minterBoardInfiniteState.loadToken) return 0 const count = await countCommentsCached(cardIdentifier, loadToken).catch( () => 0 ) if ( loadToken !== minterBoardInfiniteState.loadToken || (!document.body.contains( document.getElementById(`card-shell-${cardIdentifier}`) ) && !document.body.contains( document.getElementById(`minter-list-detail-${cardIdentifier}`) )) ) { return count } const commentButton = document.getElementById( `comment-button-${cardIdentifier}` ) if (commentButton) { commentButton.dataset.commentCount = String(count) if ( commentButton.textContent !== "HIDE COMMENTS" && commentButton.textContent !== "LOADING..." ) { commentButton.textContent = `COMMENTS (${count})` } } const listCommentCount = document.getElementById( `list-comment-count-${cardIdentifier}` ) if (listCommentCount) { listCommentCount.dataset.commentCount = String(count) listCommentCount.textContent = `${count} comment${count === 1 ? "" : "s"}` } return count } const countComments = async ( cardIdentifier, loadToken = minterBoardInfiniteState.loadToken ) => { try { const response = await searchSimple( "BLOG_POST", `comment-${cardIdentifier}`, "", 0, 0, "", "false" ) const fetchedComments = Array.isArray(response) ? response : [] const existingResourcesByIdentity = new Map( fetchedComments.map((comment) => [ getBoardResourceIdentityKey(comment), comment, ]) ) const optimisticComments = getOptimisticMinterBoardComments( cardIdentifier, existingResourcesByIdentity ) const mergedComments = [...optimisticComments, ...fetchedComments] if (loadToken === minterBoardInfiniteState.loadToken) { rememberMinterBoardCommentSnapshot(cardIdentifier, mergedComments) } return mergedComments.length } catch (error) { console.error(`Error fetching comment count for ${cardIdentifier}:`, error) const optimisticComments = getOptimisticMinterBoardComments(cardIdentifier) if (loadToken === minterBoardInfiniteState.loadToken) { rememberMinterBoardCommentSnapshot(cardIdentifier, optimisticComments) } return optimisticComments.length } } const createModal = (modalType = "") => { if (document.getElementById(`${modalType}-modal`)) { return } const isIframe = modalType === "links" const isAccountModal = modalType === "account" const modalWidth = isIframe || isAccountModal ? "92vw" : "80%" const modalHeight = isIframe || isAccountModal ? "88vh" : "70%" const modalMargin = isIframe || isAccountModal ? "4vh auto" : "10% auto" const modalBackground = isIframe || isAccountModal ? "rgba(5, 10, 14, 0.94)" : "rgba(0, 0, 0, 0.80)" const modalBorder = isIframe || isAccountModal ? "1px solid rgba(157, 193, 196, 0.28)" : "none" const modalShadow = isIframe || isAccountModal ? "0 20px 60px rgba(0, 0, 0, 0.55)" : "none" const closeButtonOnclick = modalType === "stats-compile" ? "closeStatsCompileModal()" : `closeModal('${modalType}')` const modalHTML = ` ` document.body.insertAdjacentHTML("beforeend", modalHTML) const modal = document.getElementById(`${modalType}-modal`) window.addEventListener("click", (event) => { if (event.target === modal) { if ( modalType === "stats-compile" && typeof window.getStatsCompileModalState === "function" ) { const statsModalState = window.getStatsCompileModalState() if ( statsModalState && (statsModalState.compiling || statsModalState.phase === "progress" || statsModalState.phase === "paused") ) { return } closeStatsCompileModal() return } closeModal(modalType) } }) } const openLinksModal = async (link) => { if (typeof qMintershipOpenQortalLinkPreviewModal === "function") { await qMintershipOpenQortalLinkPreviewModal(link) return } const processedLink = await processLink(link) const modal = document.getElementById("links-modal") const modalContent = document.getElementById("links-modalContent") if (modalContent) { modalContent.src = qSanitizeUrl(processedLink, "") } if (modal) { modal.style.display = "block" } } const closeModal = async (modalType = "links") => { if ( modalType === "links" && typeof qMintershipCloseQortalLinkPreviewModal === "function" ) { qMintershipCloseQortalLinkPreviewModal() return } const modal = document.getElementById(`${modalType}-modal`) const modalContent = document.getElementById(`${modalType}-modalContent`) if (modal) { modal.style.display = "none" } if (modalContent && "src" in modalContent) { modalContent.src = "" } else if (modalContent) { modalContent.innerHTML = "" } } const processLink = async (link) => { if (typeof qMintershipResolveQortalLinkPreviewUrl === "function") { return qMintershipResolveQortalLinkPreviewUrl(link) } if (link.startsWith("qortal://")) { const match = link.match(/^qortal:\/\/([^/]+)(\/.*)?$/) if (match) { const firstParam = match[1].toUpperCase() const remainingPath = match[2] || "" const themeColor = window._qdnTheme || "default" await new Promise((resolve) => setTimeout(resolve, 10)) return `/render/${firstParam}${remainingPath}?theme=${themeColor}` } } return qSanitizeUrl(link, "") } const togglePollDetails = async (cardIdentifier) => { const detailsDiv = document.getElementById(`poll-details-${cardIdentifier}`) const modal = document.getElementById(`poll-details-modal`) const modalContent = document.getElementById(`poll-details-modalContent`) if (!detailsDiv || !modal || !modalContent) return if ( detailsDiv.dataset.detailsLoaded !== "true" && detailsDiv.dataset.pollName ) { modalContent.innerHTML = typeof getBoardInlineLoadingHTML === "function" ? getBoardInlineLoadingHTML("Loading poll details...") : "Loading poll details..." modal.style.display = "block" try { const pollResults = await fetchPollResultsCached( detailsDiv.dataset.pollName ) let minterGroupMembers = cachedMinterGroup if (!Array.isArray(minterGroupMembers) || minterGroupMembers.length === 0) { minterGroupMembers = await fetchMinterGroupMembers().catch(() => []) if (typeof cachedMinterGroup !== "undefined") { cachedMinterGroup = minterGroupMembers } } let minterAdmins = cachedMinterAdmins if (getEffectiveMinterAdminCount(minterAdmins) <= 0) { minterAdmins = await fetchMinterGroupAdmins().catch(() => []) if (typeof cachedMinterAdmins !== "undefined") { cachedMinterAdmins = minterAdmins } } const pollDetails = await processPollData( pollResults, minterGroupMembers, minterAdmins, detailsDiv.dataset.nomineeName || "", detailsDiv.dataset.cardIdentifier || cardIdentifier, { includeDetails: true } ) detailsDiv.innerHTML = pollDetails?.detailsHtml || "" detailsDiv.dataset.detailsLoaded = "true" } catch (error) { console.warn( `Unable to load poll details for ${detailsDiv.dataset.pollName}:`, error ) detailsDiv.innerHTML = `

Unable to load poll details right now.

` } } modalContent.innerHTML = detailsDiv.innerHTML modal.style.display = "block" window.onclick = (event) => { if (event.target === modal) { modal.style.display = "none" } } } const toggleGroupApprovalDetails = async (buttonEl) => { if (!buttonEl) return const cardIdentifier = String(buttonEl.dataset?.cardIdentifier || "").trim() const nomineeName = String(buttonEl.dataset?.nomineeName || "").trim() let nomineeAddress = String(buttonEl.dataset?.nomineeAddress || "").trim() const modalType = "group-approval-details" createModal(modalType) const modal = document.getElementById(`${modalType}-modal`) const modalContent = document.getElementById(`${modalType}-modalContent`) if (!modal || !modalContent) return modalContent.innerHTML = typeof getBoardInlineLoadingHTML === "function" ? getBoardInlineLoadingHTML("Loading GROUP_APPROVAL transactions...") : "Loading GROUP_APPROVAL transactions..." modal.style.display = "block" try { if (!nomineeAddress && nomineeName) { nomineeAddress = await fetchOwnerAddressFromNameCached(nomineeName).catch( () => "" ) } const relevantApprovals = nomineeAddress ? await getRelevantGroupApprovalTxsForAddressCached(nomineeAddress) : [] const { tableHtml, uniqueApprovalCount } = await buildApprovalTableHtml( relevantApprovals, getNameFromAddress ) const approvalCountLabel = uniqueApprovalCount === 1 ? "1 unique approval" : `${uniqueApprovalCount} unique approvals` modalContent.innerHTML = `

GROUP_APPROVAL transactions

${qEscapeHtml(nomineeName || "This nominee")} - ${qEscapeHtml( approvalCountLabel )}

${ relevantApprovals.length > 0 ? tableHtml : `
No GROUP_APPROVAL transactions were found for this invite yet.
` }
` } catch (error) { console.warn( `Unable to load GROUP_APPROVAL transactions for ${ cardIdentifier || nomineeName }:`, error ) modalContent.innerHTML = `

GROUP_APPROVAL transactions

Unable to load GROUP_APPROVAL transactions right now.
` } window.onclick = (event) => { if (event.target === modal) { modal.style.display = "none" } } } const generateDarkPastelBackgroundBy = (name) => { let hash = 0 for (let i = 0; i < name.length; i++) { hash = (hash << 5) - hash + name.charCodeAt(i) hash |= 0 } const safeHash = Math.abs(hash) const hueSteps = 69.69 const hueIndex = safeHash % hueSteps const hueRange = 288 const hue = 140 + hueIndex * (hueRange / hueSteps) const satSteps = 13.69 const satIndex = safeHash % satSteps const saturation = 18 + satIndex * 1.333 const lightSteps = 3.69 const lightIndex = safeHash % lightSteps const lightness = 7 + lightIndex return `hsl(${hue}, ${saturation}%, ${lightness}%)` } const handleInviteMinter = async (nomineeName, cardIdentifier = "") => { try { const blockInfo = await getLatestBlockInfo() const blockHeight = blockInfo.height const minterAccountInfo = await getNameInfoCached(nomineeName) const minterAddress = await minterAccountInfo.owner let adminPublicKey let txGroupId if (blockHeight >= GROUP_APPROVAL_FEATURE_TRIGGER_HEIGHT) { if (userState.isMinterAdmin) { adminPublicKey = await getPublicKeyByName(userState.accountName) txGroupId = 694 } else { console.warn(`user is not a minter admin, cannot create invite!`) return } } else { adminPublicKey = await getPublicKeyByName(userState.accountName) txGroupId = 0 } const fee = 0.01 const timeToLive = 864000 console.log( `about to attempt group invite, minterAddress: ${minterAddress}, adminPublicKey: ${adminPublicKey}` ) const inviteTransaction = await createGroupInviteTransaction( minterAddress, adminPublicKey, 694, minterAddress, timeToLive, txGroupId, fee ) const signedTransaction = await qortalRequest({ action: "SIGN_TRANSACTION", unsignedBytes: inviteTransaction, }) console.warn(`signed transaction`, signedTransaction) const processResponse = await processTransaction(signedTransaction) if (typeof processResponse === "object") { // The successful object might have a "signature" or "type" or "approvalStatus" console.log("Invite transaction success object:", processResponse) alert( `${nomineeName} has been successfully invited! Wait for confirmation...Transaction Response: ${JSON.stringify( processResponse )}` ) if (cardIdentifier) { void notifyMinterBoardEvent({ eventType: "invite_created", cardIdentifier, nomineeName, actionIdentifier: processResponse?.signature || processResponse?.sig || `${cardIdentifier}:${nomineeName}:invite`, actorAddress: userState.accountAddress || "", transaction: processResponse, summary: `${ userState.accountName || "An admin" } started the invite process.`, }) } } else { // fallback string or something console.log("Invite transaction raw text response:", processResponse) alert(`Invite transaction response: ${JSON.stringify(processResponse)}`) } } catch (error) { console.error("Error inviting minter:", error) alert("Error inviting minter. Please try again.") } } const createInviteButtonHtml = (nomineeName, cardIdentifier) => { const safeNomineeAttr = qEscapeAttr(nomineeName) return `
` } const handleInviteMinterFromButton = (buttonEl) => { if (!buttonEl) return const nomineeName = buttonEl.dataset?.nomineeName || buttonEl.dataset?.minterName || "" const cardIdentifier = buttonEl.dataset?.cardIdentifier || "" handleInviteMinter(nomineeName, cardIdentifier) } const FEATURE_TRIGGER_CHECK_CACHE_TTL_MS = 60000 let featureTriggerCheckCache = { timestamp: 0, value: null, promise: null, } const featureTriggerCheck = async (force = false) => { const now = Date.now() const isStale = now - featureTriggerCheckCache.timestamp > FEATURE_TRIGGER_CHECK_CACHE_TTL_MS if (!force && featureTriggerCheckCache.value !== null && !isStale) { return featureTriggerCheckCache.value } if (!force && featureTriggerCheckCache.promise) { return featureTriggerCheckCache.promise } featureTriggerCheckCache.promise = (async () => { const latestBlockInfo = await getLatestBlockInfo() const isBlockPassed = latestBlockInfo.height >= GROUP_APPROVAL_FEATURE_TRIGGER_HEIGHT if (isBlockPassed) { console.warn( `featureTrigger check (verifyFeatureTrigger) determined block has PASSED:`, isBlockPassed ) featureTriggerPassed = true featureTriggerCheckCache.value = true featureTriggerCheckCache.timestamp = Date.now() return true } else { console.warn( `featureTrigger check (verifyFeatureTrigger) determined block has NOT PASSED:`, isBlockPassed ) featureTriggerPassed = false featureTriggerCheckCache.value = false featureTriggerCheckCache.timestamp = Date.now() return false } })().finally(() => { featureTriggerCheckCache.promise = null }) return featureTriggerCheckCache.promise } const getMinterInviteAdminThreshold = async () => { const isBlockPassed = await featureTriggerCheck() const minterAdmins = getEffectiveMinterAdminMembers(cachedMinterAdmins) return isBlockPassed ? Math.ceil(minterAdmins.length * 0.4) : 9 } const INVITE_CONTEXT_CACHE_TTL_MS = 15000 let inviteContextCache = { timestamp: 0, data: null, promise: null, } const getInviteContextCached = async (force = false) => { const now = Date.now() const isStale = now - inviteContextCache.timestamp > INVITE_CONTEXT_CACHE_TTL_MS if (!force && inviteContextCache.data && !isStale) { return inviteContextCache.data } if (!force && inviteContextCache.promise) { return inviteContextCache.promise } inviteContextCache.promise = fetchAllKickBanTxData() .then(({ finalKickTxs, finalBanTxs }) => { const nextData = { finalKickTxs, finalBanTxs, } inviteContextCache.data = nextData inviteContextCache.timestamp = Date.now() return nextData }) .finally(() => { inviteContextCache.promise = null }) return inviteContextCache.promise } const getMinterBoardInviteRecordsForAddresses = async ( addresses = [], force = false ) => { // The invitee-targeted invite list is the clearest signal for whether a nominee is // already in the invite flow, so we treat it as the primary invite-state source. const normalizedAddresses = Array.from( new Set( (Array.isArray(addresses) ? addresses : []) .map((address) => String(address || "").trim()) .filter(Boolean) ) ) if (normalizedAddresses.length === 0) { return [] } const inviteResponses = await Promise.all( normalizedAddresses.map((address) => fetchGroupInvitesByAddressCached(address, force).catch(() => []) ) ) const inviteMap = new Map() for (const response of inviteResponses) { for (const invite of Array.isArray(response) ? response : []) { if (Number(invite?.groupId) !== MINTER_GROUP_ID) { continue } const inviteKey = getMinterBoardTxSignature(invite) || `${String(invite?.invitee || "").trim()}::${String( invite?.creatorAddress || "" ).trim()}::${String(invite?.timestamp || "").trim()}` if (!inviteMap.has(inviteKey)) { inviteMap.set(inviteKey, invite) } } } return Array.from(inviteMap.values()) } const checkAndDisplayInviteButton = async ( adminYes, nomineeName, cardIdentifier, inviteTimelineState = null, renderVariant = "card" ) => { const isListVariant = renderVariant === "list" const isSomeTypaAdmin = userState.isAdmin || userState.isMinterAdmin const isBlockPassed = await featureTriggerCheck() // const minterAdmins = await fetchMinterGroupAdmins() const minterAdmins = getEffectiveMinterAdminMembers(cachedMinterAdmins) // default needed admin count = 9, or 40% if block has passed let minAdminCount = 9 if (isBlockPassed) { minAdminCount = Math.ceil(minterAdmins.length * 0.4) console.warn(`Using 40% => ${minAdminCount}`) } // if not enough adminYes votes, no invite button if (adminYes < minAdminCount) { console.warn( `Admin votes not high enough (have=${adminYes}, need=${minAdminCount}). No button.` ) return null } console.log( `passed initial button creation checks (adminYes >= ${minAdminCount})` ) // get nominee address from nominee name const minterNameInfo = await getNameInfoCached(nomineeName) if (!minterNameInfo || !minterNameInfo.owner) { console.warn( `No valid nameInfo for ${nomineeName}, skipping invite button.` ) return null } const minterAddress = minterNameInfo.owner const resolvedInviteTimelineState = inviteTimelineState || (await resolveMinterBoardListTimelineState(minterAddress, nomineeName)) const inviteDisplayStatus = resolvedInviteTimelineState.displayStatus || getMinterBoardInviteDisplayStatus(resolvedInviteTimelineState) if ( inviteDisplayStatus === "existing" || inviteDisplayStatus === "invited" || inviteDisplayStatus === "kicked" || inviteDisplayStatus === "banned" ) { console.warn( `Invite status for ${minterAddress} is ${inviteDisplayStatus}; ${ isListVariant ? "omitting the collapsed-row action slot." : "returning status marker instead of invite button." }` ) return isListVariant ? "" : buildMinterInviteStatusHtml(inviteDisplayStatus, { cardIdentifier, nomineeName, nomineeAddress: minterAddress, }) } const pendingInvite = resolvedInviteTimelineState.hasPendingInvite // build the normal invite button & groupApprovalHtml let inviteButtonHtml = "" if (pendingInvite) { console.warn( `There is a pending MINTER invite for this user. No create-invite button being created.` ) inviteButtonHtml = "" } else { inviteButtonHtml = isSomeTypaAdmin ? createInviteButtonHtml(nomineeName, cardIdentifier) : "" } const groupApprovalHtml = await checkGroupApprovalAndCreateButton( minterAddress, cardIdentifier, "GROUP_INVITE", { variant: renderVariant } ) console.log( `passed invite button creation checks for ${minterAddress}, resolving action buttons...` ) console.warn( `Existing Numbers - adminYes/minAdminCount: ${adminYes}/${minAdminCount}` ) if (groupApprovalHtml) { console.warn( `groupApprovalCheck found existing groupApproval, returning approval button instead of invite button...` ) return groupApprovalHtml } console.warn(`No pending approvals found, returning invite button...`) return inviteButtonHtml } const findPendingTxForAddress = async ( address, txType, limit = 0, offset = 0 ) => { const pendingTxs = await searchPendingTransactions(limit, offset, false) let relevantTypes if (txType) { relevantTypes = new Set([txType]) } else { relevantTypes = new Set([ "GROUP_INVITE", "GROUP_BAN", "GROUP_KICK", "ADD_GROUP_ADMIN", "REMOVE_GROUP_ADMIN", ]) } // Filter pending TX for relevant types const relevantTxs = pendingTxs.filter((tx) => relevantTypes.has(tx.type)) const matchedTxs = relevantTxs.filter((tx) => { switch (tx.type) { case "GROUP_INVITE": return tx.invitee === address case "GROUP_BAN": return tx.offender === address case "GROUP_KICK": return tx.member === address case "ADD_GROUP_ADMIN": return tx.member === address case "REMOVE_GROUP_ADMIN": return tx.admin === address default: return false } }) console.warn(`matchedTxs:`, matchedTxs) //Sort oldest→newest by timestamp, so matchedTxs[0] is the oldest matchedTxs.sort((a, b) => a.timestamp - b.timestamp) return matchedTxs // Array of matching pending transactions } const APPROVAL_TX_CACHE_TTL_MS = 15000 let approvalTxSearchCache = { timestamp: 0, data: null, } const pendingTxByAddressTypeCache = new Map() const inviteTxByAddressCache = new Map() const clearMinterBoardInviteStateCaches = () => { inviteContextCache.timestamp = 0 inviteContextCache.data = null inviteContextCache.promise = null approvalTxSearchCache.timestamp = 0 approvalTxSearchCache.data = null pendingTxByAddressTypeCache.clear() inviteTxByAddressCache.clear() if (typeof clearGroupInvitesByAddressCache === "function") { clearGroupInvitesByAddressCache() } } const getMinterBoardApprovalStatus = (tx = {}) => String(tx?.approvalStatus || "") .trim() .toUpperCase() const isMinterBoardPendingApprovalTx = (tx = {}) => getMinterBoardApprovalStatus(tx) === "PENDING" const isMinterBoardRejectedInviteTx = (tx = {}) => { const approvalStatus = getMinterBoardApprovalStatus(tx) return ( approvalStatus === "REJECTED" || approvalStatus === "EXPIRED" || approvalStatus === "INVALID" ) } const isMinterBoardInviteTxForAddress = (tx = {}, address = "") => Number(tx?.groupId) === MINTER_GROUP_ID && String(tx?.invitee || "").trim() === String(address || "").trim() const isMinterBoardKickTxForAddress = (tx = {}, address = "") => Number(tx?.groupId) === MINTER_GROUP_ID && String(tx?.member || "").trim() === String(address || "").trim() const isMinterBoardBanTxForAddress = (tx = {}, address = "") => Number(tx?.groupId) === MINTER_GROUP_ID && String(tx?.offender || "").trim() === String(address || "").trim() const getMinterBoardInviteTxsForAddressCached = async ( address, force = false ) => { const normalizedAddress = String(address || "").trim() if (!normalizedAddress) { return [] } const now = Date.now() const cached = inviteTxByAddressCache.get(normalizedAddress) const isStale = !cached || now - cached.timestamp > APPROVAL_TX_CACHE_TTL_MS if (!force && cached && !isStale) { return cached.data } const confirmedInviteTxs = await searchTransactions({ txTypes: ["GROUP_INVITE"], address: normalizedAddress, confirmationStatus: "CONFIRMED", limit: 0, reverse: true, offset: 0, startBlock: 1990000, blockLimit: 0, txGroupId: 0, silent: true, }).catch(() => []) const matchingInviteTxs = Array.isArray(confirmedInviteTxs) ? confirmedInviteTxs.filter((tx) => isMinterBoardInviteTxForAddress(tx, normalizedAddress) ) : [] inviteTxByAddressCache.set(normalizedAddress, { timestamp: now, data: matchingInviteTxs, }) return matchingInviteTxs } const getGroupApprovalTxsCached = async (force = false) => { const now = Date.now() const isStale = now - approvalTxSearchCache.timestamp > APPROVAL_TX_CACHE_TTL_MS if (force || !approvalTxSearchCache.data || isStale) { const [confirmedApprovals, pendingApprovals] = await Promise.all([ searchTransactions({ txTypes: ["GROUP_APPROVAL"], confirmationStatus: "CONFIRMED", limit: 0, reverse: false, offset: 0, startBlock: 1990000, blockLimit: 0, txGroupId: 0, silent: true, }).catch(() => []), searchPendingTransactions(0, 0, false) .then((pendingTxs) => Array.isArray(pendingTxs) ? pendingTxs.filter((tx) => tx.type === "GROUP_APPROVAL") : [] ) .catch(() => []), ]) approvalTxSearchCache.data = [ ...(Array.isArray(confirmedApprovals) ? confirmedApprovals : []), ...(Array.isArray(pendingApprovals) ? pendingApprovals : []), ] approvalTxSearchCache.timestamp = now } return approvalTxSearchCache.data } const getRelevantGroupApprovalTxsForAddressCached = async ( address, force = false ) => { const normalizedAddress = String(address || "").trim() if (!normalizedAddress) { return [] } const inviteTxs = await getMinterBoardInviteTxsForAddressCached( normalizedAddress, force ).catch(() => []) const latestInviteTx = Array.isArray(inviteTxs) ? [...inviteTxs].sort( (a, b) => Number(b?.timestamp || 0) - Number(a?.timestamp || 0) )[0] : null const inviteSignature = getMinterBoardTxSignature(latestInviteTx || {}) if (!inviteSignature) { return [] } const approvalTxs = await getGroupApprovalTxsCached(force).catch(() => []) return Array.isArray(approvalTxs) ? approvalTxs.filter( (approvalTx) => String(approvalTx?.pendingSignature || "").trim() === inviteSignature ) : [] } const getPendingTxForAddressCached = async ( address, transactionType, limit = 0, offset = 0, force = false ) => { const key = `${transactionType}::${address}` const now = Date.now() const cached = pendingTxByAddressTypeCache.get(key) const isStale = !cached || now - cached.timestamp > APPROVAL_TX_CACHE_TTL_MS if (force || isStale) { const data = await findPendingTxForAddress( address, transactionType, limit, offset ) pendingTxByAddressTypeCache.set(key, { timestamp: now, data }) return data } return cached.data } const checkGroupApprovalAndCreateButton = async ( address, cardIdentifier, transactionType, { variant = "card" } = {} ) => { const isListVariant = variant === "list" // We are going to be verifying that the address isn't already a minter, before showing GROUP_APPROVAL buttons potentially... if (transactionType === "GROUP_INVITE") { 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 = cachedMinterGroup const minterGroupAddresses = minterMembers.map((m) => m.member) if (minterGroupAddresses.includes(address)) { console.warn( `User is already a minter, will not be creating group_approval buttons` ) return null } } let pendingTxs = await getPendingTxForAddressCached( address, transactionType, 0, 0 ) if (transactionType === "GROUP_INVITE") { pendingTxs = pendingTxs.filter( (tx) => Number(tx.groupId) === MINTER_GROUP_ID ) } const isSomeTypaAdmin = userState.isAdmin || userState.isMinterAdmin // If no pending transaction found, return null if (!pendingTxs || pendingTxs.length === 0) { console.warn("no pending transactions found, returning null...") return null } const txSig = pendingTxs[0].signature if (isListVariant) { if (!isSomeTypaAdmin) { return null } const approvalLabel = transactionType === "GROUP_KICK" ? "Approve Kick Tx" : transactionType === "GROUP_BAN" ? "Approve Ban Tx" : "Approve Invite Tx" return ` ` } const approvalSearchResults = await getGroupApprovalTxsCached() const txGroupId = Number(pendingTxs[0]?.txGroupId) || MINTER_GROUP_ID // Find the relevant signature. (signature of the issued transaction pending.) const relevantApprovals = approvalSearchResults.filter( (approvalTx) => approvalTx.pendingSignature === txSig ) const { tableHtml, uniqueApprovalCount } = await buildApprovalTableHtml( relevantApprovals, getNameFromAddress ) if (transactionType === "GROUP_INVITE" && isSomeTypaAdmin) { const approvalButtonHtml = `

Existing ${transactionType} Approvals: ${uniqueApprovalCount}

${tableHtml}
` return approvalButtonHtml } if (transactionType === "GROUP_KICK" && isSomeTypaAdmin) { const approvalButtonHtml = `

Existing ${transactionType} Approvals: ${uniqueApprovalCount}

${tableHtml}
` return approvalButtonHtml } if (transactionType === "GROUP_BAN" && isSomeTypaAdmin) { const approvalButtonHtml = `

Existing ${transactionType} Approvals: ${uniqueApprovalCount}

${tableHtml}
` return approvalButtonHtml } if (transactionType === "ADD_GROUP_ADMIN" && isSomeTypaAdmin) { const approvalButtonHtml = `

Existing ${transactionType} Approvals: ${uniqueApprovalCount}

${tableHtml}
` return approvalButtonHtml } if (transactionType === "REMOVE_GROUP_ADMIN" && isSomeTypaAdmin) { const approvalButtonHtml = `

Existing ${transactionType} Approvals: ${uniqueApprovalCount}

${tableHtml}
` return approvalButtonHtml } } const buildApprovalTableHtml = async (approvalTxs, getNameFunc) => { // Build a Map of adminAddress => one transaction (to handle multiple approvals from same admin) const approvalMap = new Map() for (const tx of approvalTxs) { const adminAddr = tx.creatorAddress if (!approvalMap.has(adminAddr)) { approvalMap.set(adminAddr, tx) } } // Turn the map into an array for iteration const approvalArray = Array.from(approvalMap, ([adminAddr, tx]) => ({ adminAddr, tx, })) // Build table rows asynchronously, since we need getNameFromAddress const tableRows = await Promise.all( approvalArray.map(async ({ adminAddr, tx }) => { let adminName try { adminName = await getNameFunc(adminAddr) } catch (err) { console.warn(`Error fetching name for ${adminAddr}:`, err) adminName = null } const displayName = adminName && adminName !== adminAddr ? adminName : "(No registered name)" const dateStr = new Date(tx.timestamp).toLocaleString() return ` ${displayName} ${dateStr} ` }) ) // The total unique approvals = number of entries in approvalMap const uniqueApprovalCount = approvalMap.size // Wrap the table in a container with horizontal scroll: // 1) max-width: 100% makes it fit the parent (card) width // 2) overflow-x: auto allows scrolling if the table is too wide const containerHtml = `
${tableRows.join("")}
Admin Name Approval Time
` // Return both the container-wrapped table and the count of unique approvals return { tableHtml: containerHtml, uniqueApprovalCount, } } const handleGroupApproval = async ( pendingSignature, cardIdentifier = "", transactionType = "GROUP_APPROVAL" ) => { try { if (!userState.isMinterAdmin) { console.warn(`non-admin attempting to sign approval!`) return } const fee = 0.01 const adminPublicKey = await getPublicKeyFromAddress( userState.accountAddress ) const txGroupId = 0 const rawGroupApprovalTransaction = await createGroupApprovalTransaction( adminPublicKey, pendingSignature, txGroupId, fee ) const signedGroupApprovalTransaction = await qortalRequest({ action: "SIGN_TRANSACTION", unsignedBytes: rawGroupApprovalTransaction, }) let txToProcess = signedGroupApprovalTransaction const processGroupApprovalTx = await processTransaction(txToProcess) if (processGroupApprovalTx) { alert( `transaction processed, please wait for CONFIRMATION: ${JSON.stringify( processGroupApprovalTx )}` ) if (cardIdentifier) { void notifyMinterBoardEvent({ eventType: "group_approval", cardIdentifier, transactionType, pendingSignature, actionIdentifier: pendingSignature, actorAddress: userState.accountAddress || "", transaction: processGroupApprovalTx, summary: `${ userState.accountName || "An admin" } approved a pending ${transactionType} transaction.`, }) if (transactionType === "GROUP_INVITE") { window.setTimeout(() => { void loadMinterBoardDetectedUpdates().catch(() => null) }, 2500) } } } else { alert(`creating tx failed for some reason`) } } catch (error) { console.error(error) throw error } } const handleJoinGroup = async (minterAddress, cardIdentifier = "") => { try { if (userState.accountAddress === minterAddress) { console.log(`minter user found `) const qRequestAttempt = await qortalRequest({ action: "JOIN_GROUP", groupId: 694, }) if (qRequestAttempt) { if (cardIdentifier) { void notifyMinterBoardEvent({ eventType: "joined", cardIdentifier, actionIdentifier: `${cardIdentifier}:${ userState.accountAddress || minterAddress }:joined`, actorAddress: userState.accountAddress || minterAddress || "", summary: `${ userState.accountName || "The nominee" } joined the MINTER group.`, }) window.setTimeout(() => { void loadMinterBoardDetectedUpdates().catch(() => null) }, 2500) } return true } const joinerPublicKey = getPublicKeyFromAddress(minterAddress) const fee = 0.01 const joinGroupTransactionData = await createGroupJoinTransaction( minterAddress, joinerPublicKey, 694, 0, fee ) const signedJoinGroupTransaction = await qortalRequest({ action: "SIGN_TRANSACTION", unsignedBytes: joinGroupTransactionData, }) let txToProcess = signedJoinGroupTransaction const processJoinGroupTransaction = await processTransaction(txToProcess) if (processJoinGroupTransaction) { console.warn(`processed JOIN_GROUP tx`, processJoinGroupTransaction) alert( `JOIN GROUP Transaction Processed Successfully, please WAIT FOR CONFIRMATION txData: ${JSON.stringify( processJoinGroupTransaction )}` ) if (cardIdentifier) { void notifyMinterBoardEvent({ eventType: "joined", cardIdentifier, actionIdentifier: `${cardIdentifier}:${ userState.accountAddress || minterAddress }:joined`, actorAddress: userState.accountAddress || minterAddress || "", transaction: processJoinGroupTransaction, summary: `${ userState.accountName || "The nominee" } joined the MINTER group.`, }) window.setTimeout(() => { void loadMinterBoardDetectedUpdates().catch(() => null) }, 2500) } } } else { console.warn(`user is not the minter`) return "" } } catch (error) { throw error } } const getMinterAvatar = async (minterName) => { const placeholderAvatarHtml = `` const normalizedName = String(minterName ?? "") .trim() .toLowerCase() if (minterAvatarMarkupCache.has(normalizedName)) { return minterAvatarMarkupCache.get(normalizedName) } if (!minterName || minterName === "undefined" || minterName === "null") { minterAvatarMarkupCache.set(normalizedName, placeholderAvatarHtml) return placeholderAvatarHtml } const avatarUrl = `/arbitrary/THUMBNAIL/${encodeURIComponent( minterName )}/qortal_avatar` try { const response = await fetch(avatarUrl, { method: "HEAD" }) if (response.ok) { const avatarHtml = ` ` minterAvatarMarkupCache.set(normalizedName, avatarHtml) return avatarHtml } minterAvatarMarkupCache.set(normalizedName, placeholderAvatarHtml) return placeholderAvatarHtml } catch (error) { console.error("Error checking avatar availability:", error) minterAvatarMarkupCache.set(normalizedName, placeholderAvatarHtml) return placeholderAvatarHtml } } function copyAddressFromIdentityBox(buttonEl) { const address = buttonEl?.dataset?.copyAddress?.trim() if (!address) { return } const restoreTooltip = () => { const originalTitle = buttonEl?.dataset?.originalTitle if (originalTitle) { buttonEl.setAttribute("title", originalTitle) } buttonEl?.classList?.remove("is-copied") } const markCopied = () => { buttonEl?.classList?.add("is-copied") buttonEl.setAttribute("title", "Copied address") window.setTimeout(restoreTooltip, 1200) } if (navigator.clipboard?.writeText) { navigator.clipboard .writeText(address) .then(markCopied) .catch((error) => { console.warn( "Clipboard copy failed, falling back to legacy copy flow:", error ) legacyCopyAddress() }) return } legacyCopyAddress() function legacyCopyAddress() { const tempTextArea = document.createElement("textarea") tempTextArea.value = address tempTextArea.setAttribute("readonly", "") tempTextArea.style.position = "fixed" tempTextArea.style.opacity = "0" document.body.appendChild(tempTextArea) tempTextArea.select() try { document.execCommand("copy") markCopied() } catch (error) { console.warn("Legacy clipboard copy failed:", error) } finally { tempTextArea.remove() } } } function copyMinterBoardCardLink(buttonEl) { const cardIdentifier = String( buttonEl?.dataset?.shareCardIdentifier || "" ).trim() if (!cardIdentifier) { return } const routeHash = (() => { if (typeof buildBoardRouteHash === "function") { return buildBoardRouteHash({ board: "minter", cardIdentifier, section: "all", }) } return `#/minter/${encodeURIComponent(cardIdentifier)}/all` })() const absoluteUrl = (() => { try { const url = new URL(window.location.href) url.hash = routeHash const qortalPath = url.pathname.startsWith("/render/") ? url.pathname.replace(/^\/render/, "") : url.pathname return `qortal://${qortalPath}${url.search}${url.hash}` } catch (error) { console.warn("Unable to build absolute card link URL:", error) const fallbackPath = String(window.location.pathname || "").startsWith( "/render/" ) ? String(window.location.pathname || "").replace(/^\/render/, "") : String(window.location.pathname || "") return `qortal://${fallbackPath}${window.location.search || ""}${routeHash}` } })() const restoreTooltip = () => { const originalTitle = buttonEl?.dataset?.originalTitle if (originalTitle) { buttonEl.setAttribute("title", originalTitle) } buttonEl?.classList?.remove("is-copied") } const markCopied = () => { buttonEl?.classList?.add("is-copied") buttonEl.setAttribute("title", "Copied link") window.setTimeout(restoreTooltip, 1200) } if (navigator.clipboard?.writeText) { navigator.clipboard .writeText(absoluteUrl) .then(markCopied) .catch((error) => { console.warn( "Clipboard copy failed, falling back to legacy share-link copy flow:", error ) legacyCopyCardLink() }) return } legacyCopyCardLink() function legacyCopyCardLink() { const tempTextArea = document.createElement("textarea") tempTextArea.value = absoluteUrl tempTextArea.setAttribute("readonly", "") tempTextArea.style.position = "fixed" tempTextArea.style.opacity = "0" document.body.appendChild(tempTextArea) tempTextArea.select() try { document.execCommand("copy") markCopied() } catch (error) { console.warn("Legacy share-link copy failed:", error) } finally { tempTextArea.remove() } } } function buildIdentityBoxHtml( label, displayName, address, level = null, avatarHtml = "" ) { const safeLabel = qEscapeHtml(label) const safeDisplayName = qEscapeHtml(displayName || "Unknown") const normalizedAddress = address || "" const safeAddress = qEscapeAttr(normalizedAddress) const titleText = normalizedAddress ? `${label} address: ${normalizedAddress}` : `${label} address unavailable` const safeTitle = qEscapeAttr(titleText) const safeAriaLabel = qEscapeAttr( normalizedAddress ? `${label} ${displayName || "Unknown"}. Click to copy the address.` : `${label} ${displayName || "Unknown"}. Address unavailable.` ) const emptyClass = normalizedAddress ? "" : " is-empty" const hasLevelBadge = level !== null && typeof level !== "undefined" const safeLevel = hasLevelBadge ? qEscapeHtml(String(level)) : "" const levelBadgeHtml = hasLevelBadge ? ` L${safeLevel} ` : "" const avatarMarkup = String(avatarHtml || "").trim() ? avatarHtml : `` const nameTriggerHtml = typeof buildBoardAccountTriggerHtml === "function" ? buildBoardAccountTriggerHtml({ name: displayName || "Unknown", address: normalizedAddress, label: displayName || "Unknown", className: "card-identity-box-name card-account-trigger card-account-trigger--inline", tagName: "span", }) : `${safeDisplayName}` return ` ` } const getNewestCommentTimestamp = async (cardIdentifier) => { try { // fetchCommentsForCard returns resources each with at least 'created' or 'updated' const comments = await fetchCommentsForCard(cardIdentifier) if (!comments || comments.length === 0) { // No comments => fallback to 0 (or card's own date, if you like) return 0 } // The newest can be determined by comparing 'updated' or 'created' const newestTimestamp = comments.reduce((acc, c) => { const cTime = c.updated || c.created || 0 return cTime > acc ? cTime : acc }, 0) return newestTimestamp } catch (err) { console.error("Failed to get newest comment timestamp:", err) return 0 } } const getMinterBoardAdminVoteThreshold = () => { const minterAdmins = getEffectiveMinterAdminMembers(cachedMinterAdmins) if (!featureTriggerPassed || minterAdmins.length <= 1) { return MIN_ADMIN_YES_VOTES } return Math.ceil(minterAdmins.length * 0.4) } const buildMinterListStatusHtml = ({ totalYes = 0, totalNo = 0, adminYes = 0, hasApprovedInvite = false, hasPendingInvite = false, isExistingMinter = false, inviteStatus = "", }) => { const adminVoteThreshold = getMinterBoardAdminVoteThreshold() const adminSupportReached = Number(adminYes || 0) >= adminVoteThreshold const inviteStatusValue = String(inviteStatus || "") .trim() .toLowerCase() const inviteProgressReached = isExistingMinter || inviteStatusValue === "invited" || inviteStatusValue === "kicked" || inviteStatusValue === "banned" || (hasApprovedInvite && !hasPendingInvite) const inviteStepLabel = inviteStatusValue === "banned" ? "Banned" : inviteStatusValue === "kicked" ? "Kicked" : "Invited" const steps = [ { label: "New", state: "done", }, { label: "Vote on Poll", state: inviteProgressReached || adminSupportReached ? "done" : "active", }, { label: "Admin Support", state: isExistingMinter ? "done" : inviteProgressReached ? "done" : adminSupportReached ? "active" : "pending", }, { label: inviteStepLabel, state: isExistingMinter ? "done" : inviteProgressReached ? "active" : "pending", }, { label: "Joined", state: isExistingMinter ? "done" : "pending" }, ] return `
${steps .map( (step) => `
${qEscapeHtml( step.label )}
` ) .join("")}
` } const buildMinterListStateHtml = ({ isExistingMinter = false, hasApprovedInvite = false, hasPendingInvite = false, inviteStatus = "", cardIdentifier = "", nomineeName = "", nomineeAddress = "", } = {}) => { const normalizedInviteStatus = String(inviteStatus || "") .trim() .toLowerCase() if (normalizedInviteStatus) { return buildMinterInviteStatusHtml(normalizedInviteStatus, { variant: "list", cardIdentifier, nomineeName, nomineeAddress, }) } if (isExistingMinter) { return `
EXISTING MINTER
` } if (hasApprovedInvite && !hasPendingInvite) { return buildMinterInviteStatusHtml("invited", { variant: "list", cardIdentifier, nomineeName, nomineeAddress, }) } if (hasPendingInvite) { return `
INVITE PENDING APPROVAL
` } return "" } const buildMinterInviteStatusHtml = ( status = "", { variant = "card", cardIdentifier = "", nomineeName = "", nomineeAddress = "", } = {} ) => { const normalizedStatus = String(status || "") .trim() .toLowerCase() const isListVariant = variant === "list" if ( normalizedStatus !== "existing" && normalizedStatus !== "invited" && normalizedStatus !== "pending" && normalizedStatus !== "kicked" && normalizedStatus !== "banned" ) { return "" } const label = normalizedStatus === "existing" ? "EXISTING MINTER" : normalizedStatus === "pending" ? "INVITE PENDING APPROVAL" : normalizedStatus === "kicked" ? "KICKED FROM MINTER GROUP" : normalizedStatus === "banned" ? "BANNED FROM MINTER GROUP" : "INVITED" const shouldLinkToApproval = normalizedStatus === "invited" && Boolean( String(cardIdentifier || "").trim() || String(nomineeName || "").trim() || String(nomineeAddress || "").trim() ) const approvalLinkHtml = shouldLinkToApproval ? ` ${qEscapeHtml(label)} ` : qEscapeHtml(label) return `
${approvalLinkHtml}
` } const buildMinterJoinGroupButtonHtml = ({ cardIdentifier = "", variant = "card", } = {}) => { const isListVariant = variant === "list" return `
` } const buildMinterGroupApprovalDetailsButtonHtml = ({ cardIdentifier = "", nomineeName = "", nomineeAddress = "", variant = "card", } = {}) => { const isListVariant = variant === "list" return ` ` } const getMinterBoardTxSignature = (tx = {}) => String( tx?.signature || tx?.sig || tx?.txSignature || tx?.reference || "" ).trim() const isMinterBoardQortalAddress = (value = "") => /^Q[a-zA-Z0-9]{33}$/.test(String(value || "").trim()) const resolveMinterBoardListTimelineState = async ( nomineeAddress = "", nomineeName = "", force = false ) => { const normalizedAddressInput = String(nomineeAddress || "").trim() const normalizedNameInput = String(nomineeName || "").trim() if (!normalizedAddressInput && !normalizedNameInput) { return { hasApprovedInvite: false, hasPendingInvite: false, hasGroupApproval: false, hasKicked: false, hasBanned: false, displayStatus: "", } } try { const candidateAddresses = new Set() const candidateInputs = [ normalizedAddressInput, normalizedNameInput, ].filter(Boolean) for (const candidateInput of candidateInputs) { if (isMinterBoardQortalAddress(candidateInput)) { candidateAddresses.add(candidateInput) continue } const resolvedAddress = await fetchOwnerAddressFromNameCached( candidateInput ).catch(() => "") if (isMinterBoardQortalAddress(resolvedAddress)) { candidateAddresses.add(String(resolvedAddress).trim()) } } const candidateAddressList = Array.from(candidateAddresses) if (candidateAddressList.length === 0) { return { hasApprovedInvite: false, hasPendingInvite: false, hasGroupApproval: false, hasKicked: false, hasBanned: false, displayStatus: "", } } const [ inviteRecords, confirmedInviteGroups, pendingInviteGroups, inviteContext, ] = await Promise.all([ getMinterBoardInviteRecordsForAddresses( candidateAddressList, force ).catch(() => []), Promise.all( candidateAddressList.map((address) => getMinterBoardInviteTxsForAddressCached(address, force).catch( () => [] ) ) ).then((results) => results.flat()), Promise.all( candidateAddressList.map((address) => getPendingTxForAddressCached( address, "GROUP_INVITE", 0, 0, force ).catch(() => []) ) ).then((results) => results.flat()), getInviteContextCached(force).catch(() => ({ finalKickTxs: [], finalBanTxs: [], })), ]) const inviteRecordMap = new Map() for (const invite of Array.isArray(inviteRecords) ? inviteRecords : []) { const inviteKey = getMinterBoardTxSignature(invite) || `${String(invite?.invitee || "").trim()}::${String( invite?.creatorAddress || "" ).trim()}::${String(invite?.timestamp || "").trim()}` if (!inviteRecordMap.has(inviteKey)) { inviteRecordMap.set(inviteKey, invite) } } const confirmedInviteMap = new Map() for (const invite of Array.isArray(confirmedInviteGroups) ? confirmedInviteGroups : []) { const inviteKey = getMinterBoardTxSignature(invite) || `${String(invite?.invitee || "").trim()}::${String( invite?.creatorAddress || "" ).trim()}::${String(invite?.timestamp || "").trim()}` if (!confirmedInviteMap.has(inviteKey)) { confirmedInviteMap.set(inviteKey, invite) } } const pendingInviteMap = new Map() for (const invite of Array.isArray(pendingInviteGroups) ? pendingInviteGroups : []) { const inviteKey = getMinterBoardTxSignature(invite) || `${String(invite?.invitee || "").trim()}::${String( invite?.creatorAddress || "" ).trim()}::${String(invite?.timestamp || "").trim()}` if (!pendingInviteMap.has(inviteKey)) { pendingInviteMap.set(inviteKey, invite) } } const directInviteTxs = Array.from(inviteRecordMap.values()).filter( (tx) => Number(tx?.groupId) === MINTER_GROUP_ID ) const confirmedInviteTxs = Array.from(confirmedInviteMap.values()).filter( (tx) => Number(tx?.groupId) === MINTER_GROUP_ID ) const pendingInviteTxs = Array.from(pendingInviteMap.values()).filter( (tx) => Number(tx?.groupId) === MINTER_GROUP_ID ) const confirmedPendingInviteTxs = confirmedInviteTxs.filter( isMinterBoardPendingApprovalTx ) const confirmedApprovedInviteTxs = confirmedInviteTxs.filter( (tx) => !isMinterBoardPendingApprovalTx(tx) && !isMinterBoardRejectedInviteTx(tx) ) const finalKickTxs = Array.isArray(inviteContext?.finalKickTxs) ? inviteContext.finalKickTxs : [] const finalBanTxs = Array.isArray(inviteContext?.finalBanTxs) ? inviteContext.finalBanTxs : [] const hasKicked = candidateAddressList.some((address) => finalKickTxs.some((tx) => isMinterBoardKickTxForAddress(tx, address)) ) const hasBanned = candidateAddressList.some((address) => finalBanTxs.some((tx) => isMinterBoardBanTxForAddress(tx, address)) ) const hasPendingInvite = pendingInviteTxs.length > 0 || confirmedPendingInviteTxs.length > 0 const hasApprovedInvite = !hasPendingInvite && (directInviteTxs.length > 0 || confirmedApprovedInviteTxs.length > 0) const displayStatus = getMinterBoardInviteDisplayStatus({ hasApprovedInvite, hasPendingInvite, hasKicked, hasBanned, }) return { hasApprovedInvite, hasPendingInvite, hasGroupApproval: hasApprovedInvite, hasKicked, hasBanned, displayStatus, } } catch (error) { console.warn( `Unable to resolve list timeline state for ${ normalizedAddressInput || normalizedNameInput || "unknown" }:`, error ) return { hasApprovedInvite: false, hasPendingInvite: false, hasGroupApproval: false, hasKicked: false, hasBanned: false, displayStatus: "", } } } const buildMinterListCardHTML = ({ cardIdentifier, userVoteStateClass, finalBgColor, avatarHtml, nomineeLinkHtml, nomineeName, nomineeLevel, nomineeAddressValue, nominatorName, nominatorAddressValue, safeHeader, renderedContent, linksHTML, safeFormattedDate, optimisticNotice, identityBoxesHtml, penaltyText, adjustmentText, invitedText, detailsHtml, inviteHtmlAdd, adminYes, adminNo, minterYes, minterNo, totalYes, totalNo, totalYesWeight, totalNoWeight, commentCount, poll, hasApprovedInvite, hasPendingInvite, isExistingMinter, inviteStatus = "", groupApprovalHtml = "", shareButtonHtml = "", editButtonHtml, notificationButtonHtml, }) => { const safeNomineeLevel = nomineeLevel === null || typeof nomineeLevel === "undefined" ? "..." : qEscapeHtml(String(nomineeLevel)) const safeNominee = qEscapeHtml(nomineeName) const safeCardIdentifier = qEscapeHtml(cardIdentifier) const safeNomineeAddress = qEscapeHtml(nomineeAddressValue || "Unavailable") const safeNominatorName = qEscapeHtml(nominatorName || "Unknown") const safeNominatorAddress = qEscapeHtml( nominatorAddressValue || "Unavailable" ) const listStateHtml = buildMinterListStateHtml({ isExistingMinter, hasApprovedInvite, hasPendingInvite, inviteStatus, cardIdentifier, nomineeName, nomineeAddress: nomineeAddressValue, }) const listEditButtonHtml = editButtonHtml || "" return `
${avatarHtml}

${nomineeLinkHtml} Level ${safeNomineeLevel}

${safeHeader}

Nominee: ${safeNomineeAddress} Nominator: ${safeNominatorName} ${safeCardIdentifier}
Published ${safeFormattedDate}
${listStateHtml || ""}
${buildMinterListStatusHtml({ totalYes, totalNo, adminYes, hasApprovedInvite, hasPendingInvite, isExistingMinter, inviteStatus, })}
Admin Yes: ${qEscapeHtml( String(adminYes) )} Admin No: ${qEscapeHtml( String(adminNo) )} Minter Yes: ${qEscapeHtml( String(minterYes) )} Minter No: ${qEscapeHtml( String(minterNo) )}
${inviteHtmlAdd}
${qEscapeHtml(String(commentCount))} comment${ Number(commentCount) === 1 ? "" : "s" }
${notificationButtonHtml} ${shareButtonHtml}
${groupApprovalHtml}
${listEditButtonHtml}
` } const hydrateMinterBoardCardDisplay = async ({ cardResource, cardData, cardIdentifier, isExistingMinter = false, loadToken = minterBoardInfiniteState.loadToken, forceTimelineRefresh = false, }) => { if (loadToken !== minterBoardInfiniteState.loadToken) return const root = document.getElementById(`card-shell-${cardIdentifier}`) if (!root) return try { const currentCardData = cardData || {} const nomineeName = getCardNomineeName( currentCardData, cardResource?.name || "Unknown" ) const nominatorName = getCardNominatorName( currentCardData, currentCardData.publishedBy || "Unknown" ) const resolvedNomineeAddress = await resolveCardNomineeAddress( cardResource || { name: currentCardData.publishedBy || "" }, currentCardData ) const resolvedNominatorAddress = getCardNominatorAddress(currentCardData, "") || (nominatorName ? await fetchOwnerAddressFromNameCached(nominatorName).catch(() => "") : "") const isListModeHydration = Boolean(root.querySelector(".minter-list-card")) let inviteTimelineState = { hasApprovedInvite: false, hasPendingInvite: false, hasGroupApproval: false, hasKicked: false, hasBanned: false, displayStatus: "", } const [ avatarHtml, nominatorAvatarHtml, nomineeAddressInfo, nominatorAddressInfo, canEditCard, pollResultsFresh, ] = await Promise.all([ getMinterAvatar(nomineeName), getMinterAvatar(nominatorName || ""), getAddressInfoCached( resolvedNomineeAddress || cardResource?.name || "" ).catch(() => null), resolvedNominatorAddress ? getAddressInfoCached(resolvedNominatorAddress).catch(() => null) : Promise.resolve(null), canCurrentUserEditPublishedCard( nominatorName, resolvedNominatorAddress || "" ).catch(() => false), currentCardData.poll ? fetchPollResultsCached(currentCardData.poll).catch(() => null) : Promise.resolve(null), ]) if ( loadToken !== minterBoardInfiniteState.loadToken || !document.body.contains(root) ) { return } const nomineeLevel = nomineeAddressInfo?.level ?? 0 const nominatorLevel = nominatorAddressInfo?.level ?? null const nomineeAddressValue = resolvedNomineeAddress || currentCardData.nomineeAddress || "" const nominatorAddressValue = resolvedNominatorAddress || currentCardData.nominatorAddress || "" const isSomeTypaAdmin = userState.isAdmin || userState.isMinterAdmin let adminYesForInvite = 0 let inviteHtmlAdd = "" const identityRow = root.querySelector(`#identity-row-${cardIdentifier}`) if (identityRow) { identityRow.innerHTML = ` ${buildIdentityBoxHtml( "Nominee", nomineeName, nomineeAddressValue || "", nomineeLevel, avatarHtml )} ${buildIdentityBoxHtml( "Nominator", nominatorName || "Unknown", nominatorAddressValue || "", nominatorLevel, nominatorAvatarHtml )} ` } const avatarSlot = root.querySelector(`#card-avatar-${cardIdentifier}`) if (avatarSlot) { avatarSlot.innerHTML = avatarHtml } const levelSlot = root.querySelector(`#nominee-level-${cardIdentifier}`) if (levelSlot) { levelSlot.textContent = `Level ${nomineeLevel}` } const pollDetailsSlot = root.querySelector( `#poll-details-${cardIdentifier}` ) if (pollDetailsSlot) { if (pollResultsFresh) { rememberMinterBoardPollSnapshot(currentCardData.poll, pollResultsFresh) const minterGroupMembers = cachedMinterGroup const minterAdmins = cachedMinterAdmins const pollDetails = await processPollData( pollResultsFresh, minterGroupMembers, minterAdmins, nomineeName, cardIdentifier, { includeDetails: false } ) if ( loadToken !== minterBoardInfiniteState.loadToken || !document.body.contains(root) ) { return } const { adminYes = 0, adminNo = 0, minterYes = 0, minterNo = 0, totalYes = 0, totalNo = 0, totalYesWeight = 0, totalNoWeight = 0, detailsHtml = "", userVote, } = pollDetails || {} adminYesForInvite = Number(adminYes || 0) currentCardData._adminYes = adminYesForInvite const inviteAdminThreshold = await getMinterInviteAdminThreshold() const inviteGatePassed = !isExistingMinter && adminYesForInvite >= inviteAdminThreshold currentCardData._inviteEligible = inviteGatePassed inviteTimelineState = inviteGatePassed ? await resolveMinterBoardListTimelineState( resolvedNomineeAddress || currentCardData.nomineeAddress || "", nomineeName || "", forceTimelineRefresh ) : { hasApprovedInvite: false, hasPendingInvite: false, hasGroupApproval: false, hasKicked: false, hasBanned: false, displayStatus: "", } const userVoteStateClass = userVote === 0 ? "card--user-vote-yes" : userVote === 1 ? "card--user-vote-no" : "" root.classList.remove("card--user-vote-yes", "card--user-vote-no") if (userVoteStateClass) { root.classList.add(userVoteStateClass) } pollDetailsSlot.dataset.pollName = currentCardData.poll || "" pollDetailsSlot.dataset.nomineeName = nomineeName || "" pollDetailsSlot.dataset.cardIdentifier = cardIdentifier || "" pollDetailsSlot.dataset.detailsLoaded = "false" pollDetailsSlot.innerHTML = detailsHtml const adminYesSlot = root.querySelector(".admin-results .admin-yes") const adminNoSlot = root.querySelector(".admin-results .admin-no") const minterYesSlot = root.querySelector(".minter-results .minter-yes") const minterNoSlot = root.querySelector(".minter-results .minter-no") const totalYesSlot = root.querySelector(".total-results .total-yes") const totalNoSlot = root.querySelector(".total-results .total-no") const totalYesWeightSlot = root.querySelector( ".total-results .total-yes + .vote-total-weight" ) const totalNoWeightSlot = root.querySelector( ".total-results .vote-total-group:last-child .vote-total-weight" ) if (adminYesSlot) adminYesSlot.textContent = `Admin Yes: ${adminYes}` if (adminNoSlot) adminNoSlot.textContent = `Admin No: ${adminNo}` if (minterYesSlot) minterYesSlot.textContent = `Minter Yes: ${minterYes}` if (minterNoSlot) minterNoSlot.textContent = `Minter No: ${minterNo}` if (totalYesSlot) totalYesSlot.textContent = `Total Yes: ${totalYes}` if (totalNoSlot) totalNoSlot.textContent = `Total No: ${totalNo}` if (totalYesWeightSlot) totalYesWeightSlot.textContent = `Weight: ${totalYesWeight}` if (totalNoWeightSlot) totalNoWeightSlot.textContent = `Weight: ${totalNoWeight}` const listStatusTrack = root.querySelector(".minter-list-status-track") if (listStatusTrack) { if ( loadToken !== minterBoardInfiniteState.loadToken || !document.body.contains(root) ) { return } listStatusTrack.outerHTML = buildMinterListStatusHtml({ totalYes, totalNo, adminYes, hasApprovedInvite: inviteTimelineState.hasApprovedInvite, hasPendingInvite: inviteTimelineState.hasPendingInvite, isExistingMinter, inviteStatus: inviteTimelineState.displayStatus || getMinterBoardInviteDisplayStatus(inviteTimelineState), }) } const listAdminYesSlot = root.querySelector( ".minter-list-votes .admin-yes" ) const listAdminNoSlot = root.querySelector( ".minter-list-votes .admin-no" ) const listMinterYesSlot = root.querySelector( ".minter-list-votes .minter-yes" ) const listMinterNoSlot = root.querySelector( ".minter-list-votes .minter-no" ) if (listAdminYesSlot) listAdminYesSlot.textContent = `Admin Yes: ${adminYes}` if (listAdminNoSlot) listAdminNoSlot.textContent = `Admin No: ${adminNo}` if (listMinterYesSlot) listMinterYesSlot.textContent = `Minter Yes: ${minterYes}` if (listMinterNoSlot) listMinterNoSlot.textContent = `Minter No: ${minterNo}` const listStateSlot = root.querySelector( `#minter-list-state-${cardIdentifier}` ) if (listStateSlot) { const listStateHtml = buildMinterListStateHtml({ isExistingMinter, hasApprovedInvite: inviteTimelineState.hasApprovedInvite, hasPendingInvite: inviteTimelineState.hasPendingInvite, inviteStatus: inviteTimelineState.displayStatus || getMinterBoardInviteDisplayStatus(inviteTimelineState), cardIdentifier, nomineeName, nomineeAddress: nomineeAddressValue, }) listStateSlot.innerHTML = listStateHtml listStateSlot.style.display = listStateHtml ? "" : "none" } } else { currentCardData._inviteEligible = false pollDetailsSlot.innerHTML = `
No poll data found for this nomination yet.
` } } const inviteDisplayStatus = inviteTimelineState.displayStatus || getMinterBoardInviteDisplayStatus(inviteTimelineState) || String(currentCardData._inviteDisplayStatus || "") .trim() .toLowerCase() currentCardData._inviteDisplayStatus = inviteDisplayStatus const inviteApprovalViewerVisible = currentCardData._inviteEligible === true || inviteDisplayStatus === "invited" || inviteDisplayStatus === "pending" const inviteHasBeenApprovedForDisplay = inviteDisplayStatus === "invited" const inviteHasPendingForDisplay = inviteDisplayStatus === "pending" const inviteIsKickedForDisplay = inviteDisplayStatus === "kicked" const inviteIsBannedForDisplay = inviteDisplayStatus === "banned" const inviteStatusHtml = buildMinterInviteStatusHtml( inviteDisplayStatus || (isExistingMinter ? "existing" : ""), { variant: isListModeHydration ? "list" : "card", cardIdentifier, nomineeName, nomineeAddress: nomineeAddressValue || "", } ) const inviteJoinButtonHtml = inviteHasBeenApprovedForDisplay && (userState.accountName === nomineeName || userState.accountAddress === nomineeAddressValue) ? buildMinterJoinGroupButtonHtml({ cardIdentifier, variant: isListModeHydration ? "list" : "card", }) : "" const inviteCardBackgroundColor = inviteHasBeenApprovedForDisplay ? "black" : inviteIsKickedForDisplay ? "rgb(29, 7, 4)" : inviteIsBannedForDisplay ? "rgb(24, 3, 3)" : "" if (isListModeHydration) { root.style.setProperty( "--minter-list-accent", inviteCardBackgroundColor || finalBgColor ) } if (inviteCardBackgroundColor) { root.style.backgroundColor = inviteCardBackgroundColor } root.classList.toggle("card--invited", inviteDisplayStatus === "invited") root.classList.toggle("card--kicked", inviteDisplayStatus === "kicked") root.classList.toggle("card--banned", inviteDisplayStatus === "banned") const inviteStateSlot = root.querySelector( isListModeHydration ? `#minter-list-state-${cardIdentifier}` : `#invite-state-slot-${cardIdentifier}` ) if (inviteStateSlot) { inviteStateSlot.innerHTML = inviteStatusHtml inviteStateSlot.style.display = inviteStatusHtml ? "" : "none" } const inviteJoinSlot = root.querySelector( `#invite-join-slot-${cardIdentifier}` ) if (inviteJoinSlot) { inviteJoinSlot.innerHTML = inviteJoinButtonHtml } const groupApprovalSlot = root.querySelector( `#group-approval-slot-${cardIdentifier}` ) if (groupApprovalSlot) { const groupApprovalHtml = inviteApprovalViewerVisible ? buildMinterGroupApprovalDetailsButtonHtml({ cardIdentifier, nomineeName, nomineeAddress: nomineeAddressValue || "", variant: isListModeHydration ? "list" : "card", }) : "" groupApprovalSlot.innerHTML = groupApprovalHtml groupApprovalSlot.style.display = groupApprovalHtml ? "" : "none" } rememberMinterBoardInviteSnapshot(cardIdentifier, { ...inviteTimelineState, isExistingMinter, }) const inviteSlot = root.querySelector( `#invite-button-slot-${cardIdentifier}` ) if (inviteSlot) { if ( isExistingMinter || inviteDisplayStatus === "invited" || inviteDisplayStatus === "kicked" || inviteDisplayStatus === "banned" ) { inviteHtmlAdd = isListModeHydration ? "" : buildMinterInviteStatusHtml(inviteDisplayStatus, { variant: "card", cardIdentifier, nomineeName, nomineeAddress: nomineeAddressValue || "", }) } else if (isSomeTypaAdmin) { inviteHtmlAdd = await checkAndDisplayInviteButton( adminYesForInvite, nomineeName, cardIdentifier, inviteTimelineState, isListModeHydration ? "list" : "card" ).catch(() => "") } else { inviteHtmlAdd = "" } inviteSlot.innerHTML = inviteHtmlAdd inviteSlot.style.display = String(inviteHtmlAdd || "").trim() ? "" : "none" } const supportResultsLoadingSlot = root.querySelector( `#support-results-loading-${cardIdentifier}` ) if (supportResultsLoadingSlot) { supportResultsLoadingSlot.style.display = "none" } const editSlot = root.querySelector(`#edit-button-slot-${cardIdentifier}`) if (editSlot) { editSlot.innerHTML = canEditCard ? ` ` : "" } const cachedCommentCount = commentCountCache.get(cardIdentifier) if (typeof cachedCommentCount !== "undefined") { const commentCountValue = Number(cachedCommentCount) const commentButton = root.querySelector( `#comment-button-${cardIdentifier}` ) if (commentButton) { commentButton.dataset.commentCount = String(commentCountValue) if ( commentButton.textContent !== "HIDE COMMENTS" && commentButton.textContent !== "LOADING..." ) { commentButton.textContent = `COMMENTS (${commentCountValue})` } } const listCommentCount = root.querySelector( `#list-comment-count-${cardIdentifier}` ) if (listCommentCount) { listCommentCount.dataset.commentCount = String(commentCountValue) listCommentCount.textContent = `${commentCountValue} comment${ commentCountValue === 1 ? "" : "s" }` } } const listFooter = root.querySelector(".minter-list-detail-footer") if (listFooter) { listFooter.innerHTML = ` Nominee address: ${qEscapeHtml( nomineeAddressValue || "Unavailable" )} Nominator address: ${qEscapeHtml( nominatorAddressValue || "Unavailable" )} ` } minterBoardCardDataByIdentifier.set(cardIdentifier, { ...currentCardData, nominee: nomineeName, nomineeAddress: nomineeAddressValue, nominator: nominatorName, nominatorAddress: nominatorAddressValue, _inviteDisplayStatus: inviteDisplayStatus, }) } catch (error) { console.warn(`Unable to hydrate nomination card ${cardIdentifier}:`, error) } } const toggleMinterListDetails = async (cardIdentifier, buttonEl) => { const detail = document.getElementById(`minter-list-detail-${cardIdentifier}`) if (!detail) return const isHidden = detail.hidden const shouldShowComments = String(buttonEl?.dataset?.showComments || "false").toLowerCase() === "true" if (isHidden && !shouldShowComments) { await setMinterListCommentsVisibility(cardIdentifier, false) } detail.hidden = !isHidden const controls = document.querySelectorAll(`[aria-controls="${detail.id}"]`) controls.forEach((control) => { control.setAttribute("aria-expanded", isHidden ? "true" : "false") control.textContent = isHidden ? control.dataset.expandedLabel || "Hide" : control.dataset.collapsedLabel || "View" }) if (isHidden && shouldShowComments) { await setMinterListCommentsVisibility(cardIdentifier, true) } else { await setMinterListCommentsVisibility(cardIdentifier, false) } } const getCurrentMinterNotificationVoteType = () => { if (userState.isMinterAdmin || userState.isAdmin) { return "admin_vote" } const minterAddresses = (cachedMinterGroup || []).map( (member) => member.member ) if (minterAddresses.includes(userState.accountAddress)) { return "minter_vote" } return "user_vote" } const voteOnMinterCardPoll = async (cardIdentifier, poll, optionIndex) => { if (optionIndex === 0) { await voteYesOnPoll(poll) } else { await voteNoOnPoll(poll) } const eventType = getCurrentMinterNotificationVoteType() await notifyMinterBoardEvent({ eventType, cardIdentifier, poll, vote: optionIndex === 0 ? "yes" : "no", actionIdentifier: `${cardIdentifier}:${poll}:${optionIndex}:${ userState.accountAddress || "unknown" }`, actorAddress: userState.accountAddress || "", summary: `${userState.accountName || "A user"} voted ${ optionIndex === 0 ? "YES" : "NO" }.`, }) } const voteYesOnMinterCard = async (cardIdentifier, poll) => { await voteOnMinterCardPoll(cardIdentifier, poll, 0) } const voteNoOnMinterCard = async (cardIdentifier, poll) => { await voteOnMinterCardPoll(cardIdentifier, poll, 1) } // Create the overall Minter Card HTML ----------------------------------------------- const createCardHTML = async ( cardData, pollResults, cardIdentifier, commentCount, cardUpdatedTime, bgColor, address, isExistingMinter = false ) => { const quickCardData = cardData || {} const quickNomineeName = getCardNomineeName( quickCardData, quickCardData.creator || "Unknown" ) const quickNomineeAddressValue = getCardNomineeAddress( quickCardData, address || quickCardData.creatorAddress || quickCardData.nomineeAddress || "" ) const quickNominatorName = getCardNominatorName( quickCardData, quickCardData.publishedBy || "Unknown" ) const quickNominatorAddressValue = getCardNominatorAddress( quickCardData, quickCardData.publishedByAddress || quickCardData.nominatorAddress || "" ) const formattedDate = cardUpdatedTime ? new Date(cardUpdatedTime).toLocaleString() : new Date(quickCardData.timestamp || Date.now()).toLocaleString() const placeholderAvatarHtml = `` const safeQuickHeader = qEscapeHtml(String(quickCardData.header || "")) const safeQuickFormattedDate = qEscapeHtml(formattedDate) const safeQuickNominee = qEscapeHtml(quickNomineeName) const quickNomineeLinkHtml = typeof buildBoardAccountTriggerHtml === "function" ? buildBoardAccountTriggerHtml({ name: quickNomineeName || "Unknown", address: quickNomineeAddressValue || "", label: quickNomineeName || "Unknown", className: "card-account-trigger card-account-trigger--heading", tagName: "button", }) : safeQuickNominee const quickIdentityBoxesHtml = `
${buildIdentityBoxHtml( "Nominee", quickNomineeName, quickNomineeAddressValue || "", null, placeholderAvatarHtml )} ${buildIdentityBoxHtml( "Nominator", quickNominatorName || "Unknown", quickNominatorAddressValue || "", null, placeholderAvatarHtml )}
` const quickNotificationButtonHtml = buildMinterCardNotificationButtonHtml(cardIdentifier) const quickShareButtonHtml = buildMinterBoardShareLinkButtonHtml({ cardIdentifier, variant: "card", }) const quickEditButtonHtml = `
` const quickActionButtonsHtml = `
${quickShareButtonHtml} ${quickEditButtonHtml}
` const quickInviteHtmlAdd = `
` const quickDetailsHtml = `
Loading current approval results...
` const quickSupportResultsLoadingHtml = `
${getBoardInlineLoadingHTML("Loading current approval results...")}
` const quickOptimisticNotice = quickCardData._optimisticPending ? `
Published locally. Waiting for QDN indexing.
` : "" const quickInviteDisplayStatus = String( quickCardData._inviteDisplayStatus || "" ) .trim() .toLowerCase() const quickInviteEligible = Boolean(quickCardData._inviteEligible) const quickApprovalViewerVisible = quickInviteEligible || quickInviteDisplayStatus === "invited" || quickInviteDisplayStatus === "pending" const isListMode = getMinterBoardDisplayMode() === "list" const quickInvitedText = quickInviteDisplayStatus ? buildMinterInviteStatusHtml(quickInviteDisplayStatus, { variant: "card", cardIdentifier, nomineeName: quickNomineeName, nomineeAddress: quickNomineeAddressValue || "", }) : isExistingMinter ? `

EXISTING MINTER

` : "" const quickGroupApprovalHtml = quickApprovalViewerVisible ? buildMinterGroupApprovalDetailsButtonHtml({ cardIdentifier, nomineeName: quickNomineeName, nomineeAddress: quickNomineeAddressValue || "", variant: isListMode ? "list" : "card", }) : "" const quickUserVoteStateClass = "" let quickFinalBgColor = bgColor if (quickInviteDisplayStatus === "invited") { quickFinalBgColor = "black" } else if (quickInviteDisplayStatus === "kicked") { quickFinalBgColor = "rgb(29, 7, 4)" } else if (quickInviteDisplayStatus === "banned") { quickFinalBgColor = "rgb(24, 3, 3)" } else if (isExistingMinter) { quickFinalBgColor = "rgb(99, 99, 99)" } const quickNomineeLevelLabel = "..." const quickCommentCount = Number(commentCount || 0) minterBoardCardDataByIdentifier.set(cardIdentifier, { ...quickCardData, nominee: quickNomineeName, nomineeAddress: quickNomineeAddressValue, nominator: quickNominatorName, nominatorAddress: quickNominatorAddressValue, _inviteDisplayStatus: quickInviteDisplayStatus, }) createModal("links") createModal("poll-details") if (quickCardData.poll) { void fetchPollResultsCached(quickCardData.poll).catch(() => null) } if (isListMode) { return buildMinterListCardHTML({ cardIdentifier, userVoteStateClass: quickUserVoteStateClass, finalBgColor: quickFinalBgColor, avatarHtml: placeholderAvatarHtml, nomineeLinkHtml: quickNomineeLinkHtml, nomineeName: quickNomineeName, nomineeLevel: null, nomineeAddressValue: quickNomineeAddressValue, nominatorName: quickNominatorName, nominatorAddressValue: quickNominatorAddressValue, safeHeader: safeQuickHeader, renderedContent: qRenderRichContentHtml(quickCardData.content || ""), linksHTML: (Array.isArray(quickCardData.links) ? quickCardData.links : []) .map( (link, index) => ` ` ) .join(""), safeFormattedDate: safeQuickFormattedDate, optimisticNotice: quickOptimisticNotice, identityBoxesHtml: quickIdentityBoxesHtml, penaltyText: "", adjustmentText: "", invitedText: quickInvitedText, detailsHtml: quickDetailsHtml, inviteHtmlAdd: quickInviteHtmlAdd, adminYes: "...", adminNo: "...", minterYes: "...", minterNo: "...", totalYes: "...", totalNo: "...", totalYesWeight: "...", totalNoWeight: "...", commentCount: quickCommentCount, poll: quickCardData.poll || "", hasApprovedInvite: false, hasPendingInvite: false, isExistingMinter, inviteStatus: quickInviteDisplayStatus, groupApprovalHtml: quickGroupApprovalHtml, editButtonHtml: quickEditButtonHtml, notificationButtonHtml: quickNotificationButtonHtml, }) } const quickLinksArray = Array.isArray(quickCardData.links) ? quickCardData.links : [] const quickLinksHTML = quickLinksArray .map( (link, index) => ` ` ) .join("") return `
${quickNotificationButtonHtml} ${quickActionButtonsHtml}
${placeholderAvatarHtml}

${quickNomineeLinkHtml} - Level ${quickNomineeLevelLabel}

${quickIdentityBoxesHtml}
${safeQuickHeader}
${quickOptimisticNotice} ${quickInvitedText}
NOMINATION STATEMENT
${qRenderRichContentHtml(quickCardData.content || "")}
NOMINATION LINKS
CURRENT SUPPORT RESULTS
${quickSupportResultsLoadingHtml} ${quickInviteHtmlAdd}
Admin Yes: ... Admin No: ...
Minter Yes: ... Minter No: ...
Total Yes: ... Weight: ...
Total No: ... Weight: ...
SUPPORT NOMINATION FOR
${safeQuickNominee}

(click COMMENTS button to open/close card comments)

Published ${safeQuickFormattedDate}

` }