// // 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.
CREATE NOMINATION
REFRESH
`
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.
Load New Data
`
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 = `
`
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 `
${qEscapeHtml(visibleLabel)}
`
}
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.
${qEscapeHtml(joinLabel)}
`
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.
Publish Q-Chat to ${qEscapeHtml(broadcastGroupLabel)}
Publish Hub-compatible group announcement for ${qEscapeHtml(
broadcastGroupLabel
)}
${
!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.
Join Notifications Group
`
: ""
}
${
hasDirectRecipients
? `
Show individual Q-Chat / Q-Mail recipients
Individual notifications
Use these checkboxes when you want to send direct Q-Chat and/or Q-Mail messages to the nominator, nominee, minter admins, reply author, and any other tracked users.
Check All Direct
Clear All Direct
${recipientSections
.map(
(section) => `
${section.recipients
.map((recipient) => {
const recipientKey =
getMinterNotificationRecipientKey(recipient)
const qchatPublished =
isMinterNotificationRecipientPublished(
batch.existingRecord,
recipient,
"qchat"
) ||
alreadySentQchatRecipients.has(recipientKey)
const qmailPublished =
isMinterNotificationRecipientPublished(
batch.existingRecord,
recipient,
"qmail"
) ||
alreadyPublishedQmailRecipients.has(
recipientKey
)
const sourceTags = Array.isArray(
recipient.sources
)
? recipient.sources
.map(
(source) =>
`
${qEscapeHtml(source)} `
)
.join("")
: ""
const addressLine =
recipient.address &&
recipient.address !== recipient.name
? `
${qEscapeHtml(
recipient.address
)} `
: ""
const qchatLabel = qchatPublished
? "Q-Chat sent"
: "Q-Chat"
const qmailLabel = qmailPublished
? "Q-Mail sent"
: "Q-Mail"
return `
`
})
.join("")}
`
)
.join("")}
`
: `
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 `
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 `
Voter Name/Address
Voter Type
Voter Weight(=BlocksMinted)
${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 `
${safeDisplayName}
${userType}
${v.blocksMinted}
`
})
.join("")}
`
}
// 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"
? ``
: ""
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 = ``
} 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",
})
: ``
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 `
`
} 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 = `
${
isIframe
? `
`
: `
`
}
X
`
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 `
Create Minter Invite
`
}
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 `
${qEscapeHtml(approvalLabel)}
`
}
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}
Approve Invite Tx
`
return approvalButtonHtml
}
if (transactionType === "GROUP_KICK" && isSomeTypaAdmin) {
const approvalButtonHtml = `
Existing ${transactionType} Approvals: ${uniqueApprovalCount}
${tableHtml}
Approve Kick Tx
`
return approvalButtonHtml
}
if (transactionType === "GROUP_BAN" && isSomeTypaAdmin) {
const approvalButtonHtml = `
Existing ${transactionType} Approvals: ${uniqueApprovalCount}
${tableHtml}
Approve Ban Tx
`
return approvalButtonHtml
}
if (transactionType === "ADD_GROUP_ADMIN" && isSomeTypaAdmin) {
const approvalButtonHtml = `
Existing ${transactionType} Approvals: ${uniqueApprovalCount}
${tableHtml}
Approve Add-Admin Tx
`
return approvalButtonHtml
}
if (transactionType === "REMOVE_GROUP_ADMIN" && isSomeTypaAdmin) {
const approvalButtonHtml = `
Existing ${transactionType} Approvals: ${uniqueApprovalCount}
${tableHtml}
Approve Remove-Admin Tx
`
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 = `
Admin Name
Approval Time
${tableRows.join("")}
`
// 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 `
${safeLabel}
${avatarMarkup}
${nameTriggerHtml}
${levelBadgeHtml}
`
}
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 `
Join MINTER Group
`
}
const buildMinterGroupApprovalDetailsButtonHtml = ({
cardIdentifier = "",
nomineeName = "",
nomineeAddress = "",
variant = "card",
} = {}) => {
const isListVariant = variant === "list"
return `
View approval data
`
}
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}
${notificationButtonHtml}
${shareButtonHtml}
${groupApprovalHtml}
View
${listEditButtonHtml}
${identityBoxesHtml}
${penaltyText}${adjustmentText}${optimisticNotice}
${renderedContent}
${linksHTML}
Display Poll Details
${detailsHtml}
Admin Yes: ${adminYes}
Admin No: ${adminNo}
Minter Yes: ${minterYes}
Minter No: ${minterNo}
Total Yes: ${totalYes}
Weight: ${totalYesWeight}
Total No: ${totalNo}
Weight: ${totalNoWeight}
`
}
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) => `
${qEscapeHtml(`Link ${index + 1} - ${link}`)}
`
)
.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) => `
${qEscapeHtml(`Link ${index + 1} - ${link}`)}
`
)
.join("")
return `
${quickNotificationButtonHtml}
${quickActionButtonsHtml}
${qRenderRichContentHtml(quickCardData.content || "")}
${quickLinksHTML}
${quickSupportResultsLoadingHtml}
Display Poll Details
${quickDetailsHtml}
${quickInviteHtmlAdd}
Admin Yes: ...
Admin No: ...
Minter Yes: ...
Minter No: ...
Total Yes: ...
Weight: ...
Total No: ...
Weight: ...
Published ${safeQuickFormattedDate}
`
}
${commenterNameHtml} ${levelBadgeHtml} ${adminBadge}