1835 lines
53 KiB
JavaScript
1835 lines
53 KiB
JavaScript
// This is a Helper Script that will contain the functions that are accessed from multiple different scripts in the app. Allowing this script to be loaded first, will ensure they all have awareness of them and will allow future development to be simpler.
|
|
|
|
let blockedNamesIdentifier = "Q-Mintership-blockedNames"
|
|
|
|
// Kakashi Note: Core escaping helper used across boards to keep untrusted text from executing as markup.
|
|
// Basic output-encoding helper for untrusted text that will be inserted into HTML strings.
|
|
const qEscapeHtml = (value) => {
|
|
return String(value ?? "")
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'")
|
|
}
|
|
|
|
// Attribute-safe variant. Also escapes backticks to avoid template literal edge cases.
|
|
const qEscapeAttr = (value) => {
|
|
return qEscapeHtml(value).replace(/`/g, "`")
|
|
}
|
|
|
|
const qIsSafeUrl = (url) => {
|
|
const raw = String(url ?? "").trim()
|
|
if (!raw) return false
|
|
const lower = raw.toLowerCase()
|
|
if (
|
|
lower.startsWith("javascript:") ||
|
|
lower.startsWith("data:") ||
|
|
lower.startsWith("vbscript:")
|
|
) {
|
|
return false
|
|
}
|
|
if (lower.startsWith("qortal://")) return true
|
|
if (lower.startsWith("/")) return true
|
|
if (lower.startsWith("./") || lower.startsWith("../")) return true
|
|
if (lower.startsWith("#")) return true
|
|
if (lower.startsWith("http://") || lower.startsWith("https://")) return true
|
|
if (lower.startsWith("mailto:")) return true
|
|
return false
|
|
}
|
|
|
|
const qSanitizeUrl = (url, fallback = "#") => {
|
|
const safe = String(url ?? "").trim()
|
|
return qIsSafeUrl(safe) ? safe : fallback
|
|
}
|
|
|
|
const Q_RICH_TEXT_ALLOWED_TAGS = new Set([
|
|
"A",
|
|
"B",
|
|
"BLOCKQUOTE",
|
|
"BR",
|
|
"CODE",
|
|
"DIV",
|
|
"EM",
|
|
"H1",
|
|
"H2",
|
|
"H3",
|
|
"H4",
|
|
"H5",
|
|
"H6",
|
|
"I",
|
|
"LI",
|
|
"OL",
|
|
"P",
|
|
"PRE",
|
|
"S",
|
|
"SPAN",
|
|
"STRONG",
|
|
"U",
|
|
"UL",
|
|
])
|
|
|
|
const Q_RICH_TEXT_ALLOWED_ATTRS = new Map([
|
|
["*", new Set(["class", "dir"])],
|
|
["A", new Set(["href", "target", "rel"])],
|
|
])
|
|
|
|
const qSanitizeRichHtml = (inputHtml) => {
|
|
// Kakashi Note: Rich-text sanitizer strips dangerous tags/attrs while preserving safe formatting needed for forum content.
|
|
const template = document.createElement("template")
|
|
template.innerHTML = String(inputHtml ?? "")
|
|
|
|
const dangerousTags = new Set([
|
|
"BASE",
|
|
"FORM",
|
|
"IFRAME",
|
|
"INPUT",
|
|
"LINK",
|
|
"META",
|
|
"OBJECT",
|
|
"EMBED",
|
|
"SCRIPT",
|
|
"STYLE",
|
|
"SVG",
|
|
"MATH",
|
|
"TEXTAREA",
|
|
"SELECT",
|
|
"BUTTON",
|
|
"OPTION",
|
|
])
|
|
|
|
const sanitizeNode = (rootNode) => {
|
|
const children = Array.from(rootNode.childNodes)
|
|
|
|
for (const child of children) {
|
|
if (child.nodeType === Node.COMMENT_NODE) {
|
|
child.remove()
|
|
continue
|
|
}
|
|
|
|
if (child.nodeType !== Node.ELEMENT_NODE) {
|
|
continue
|
|
}
|
|
|
|
const tag = child.tagName.toUpperCase()
|
|
|
|
if (dangerousTags.has(tag)) {
|
|
child.remove()
|
|
continue
|
|
}
|
|
|
|
if (!Q_RICH_TEXT_ALLOWED_TAGS.has(tag)) {
|
|
const fragment = document.createDocumentFragment()
|
|
while (child.firstChild) {
|
|
fragment.appendChild(child.firstChild)
|
|
}
|
|
child.replaceWith(fragment)
|
|
sanitizeNode(fragment)
|
|
continue
|
|
}
|
|
|
|
const allowedForTag = Q_RICH_TEXT_ALLOWED_ATTRS.get(tag) || new Set()
|
|
const allowedGlobal = Q_RICH_TEXT_ALLOWED_ATTRS.get("*") || new Set()
|
|
|
|
for (const attr of Array.from(child.attributes)) {
|
|
const attrName = attr.name.toLowerCase()
|
|
|
|
if (attrName.startsWith("on") || attrName === "style") {
|
|
child.removeAttribute(attr.name)
|
|
continue
|
|
}
|
|
|
|
const attrAllowed =
|
|
attrName.startsWith("data-") ||
|
|
allowedForTag.has(attrName) ||
|
|
allowedGlobal.has(attrName)
|
|
if (!attrAllowed) {
|
|
child.removeAttribute(attr.name)
|
|
continue
|
|
}
|
|
|
|
if (attrName === "href" || attrName === "src") {
|
|
const safeUrl = qSanitizeUrl(attr.value, "")
|
|
if (!safeUrl) {
|
|
child.removeAttribute(attr.name)
|
|
} else {
|
|
child.setAttribute(attr.name, safeUrl)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (tag === "A") {
|
|
const href = child.getAttribute("href")
|
|
if (!href) {
|
|
child.removeAttribute("target")
|
|
child.removeAttribute("rel")
|
|
} else {
|
|
const target = child.getAttribute("target")
|
|
if (target && target !== "_blank") {
|
|
child.removeAttribute("target")
|
|
}
|
|
child.setAttribute("rel", "noopener noreferrer")
|
|
}
|
|
}
|
|
|
|
sanitizeNode(child)
|
|
}
|
|
}
|
|
|
|
sanitizeNode(template.content)
|
|
return template.innerHTML
|
|
}
|
|
|
|
const qRenderBoardCommentHtml = (inputHtml) => {
|
|
const raw = String(inputHtml ?? "")
|
|
if (!raw.trim()) {
|
|
return ""
|
|
}
|
|
|
|
const looksLikeHtml = /<\/?[a-z][\s\S]*>/i.test(raw)
|
|
if (looksLikeHtml) {
|
|
return qSanitizeRichHtml(raw)
|
|
}
|
|
|
|
return qEscapeHtml(raw)
|
|
}
|
|
|
|
const qRenderRichContentHtml = (inputHtml) => qRenderBoardCommentHtml(inputHtml)
|
|
|
|
const boardRichTextEditorInstances = new Map()
|
|
const boardIdentityLevelCache = new Map()
|
|
const boardAccountNamesCache = new Map()
|
|
const boardAccountSponsorshipCache = new Map()
|
|
const boardAccountTransactionPageCache = new Map()
|
|
const boardAccountInspectorState = {
|
|
requestId: 0,
|
|
address: "",
|
|
displayName: "",
|
|
resolvedName: "",
|
|
txOffset: 0,
|
|
txLimit: 200,
|
|
txHasMore: false,
|
|
txLoadingMore: false,
|
|
transactions: [],
|
|
names: [],
|
|
sponsorship: null,
|
|
addressInfo: null,
|
|
}
|
|
const BOARD_RICH_TEXT_TOOLBAR_OPTIONS = [
|
|
[{ header: [2, 3, false] }],
|
|
["bold", "italic"],
|
|
[{ list: "bullet" }],
|
|
["clean"],
|
|
]
|
|
const BOARD_RICH_TEXT_EDITOR_FORMATS = ["header", "bold", "italic", "list"]
|
|
|
|
const getBoardRichTextEditorId = (editorKey) =>
|
|
`board-richtext-${editorKey}`
|
|
|
|
const getBoardRichTextComposerHtml = (editorKey, composerClass = "richtext-compose") => `
|
|
<div class="${composerClass}">
|
|
<div
|
|
id="${qEscapeAttr(getBoardRichTextEditorId(editorKey))}"
|
|
class="richtext-editor"
|
|
></div>
|
|
</div>
|
|
`
|
|
|
|
const ensureBoardRichTextEditor = (
|
|
editorKey,
|
|
placeholder = "Write a comment..."
|
|
) => {
|
|
if (typeof Quill !== "function") {
|
|
return null
|
|
}
|
|
|
|
const editorId = getBoardRichTextEditorId(editorKey)
|
|
if (boardRichTextEditorInstances.has(editorId)) {
|
|
return boardRichTextEditorInstances.get(editorId)
|
|
}
|
|
|
|
const editorEl = document.getElementById(editorId)
|
|
if (!editorEl) {
|
|
return null
|
|
}
|
|
|
|
const quill = new Quill(editorEl, {
|
|
theme: "snow",
|
|
placeholder,
|
|
formats: BOARD_RICH_TEXT_EDITOR_FORMATS,
|
|
modules: {
|
|
toolbar: BOARD_RICH_TEXT_TOOLBAR_OPTIONS,
|
|
},
|
|
})
|
|
|
|
boardRichTextEditorInstances.set(editorId, quill)
|
|
return quill
|
|
}
|
|
|
|
const getBoardRichTextEditorInstance = (editorKey) => {
|
|
const editorId = getBoardRichTextEditorId(editorKey)
|
|
return boardRichTextEditorInstances.get(editorId) || null
|
|
}
|
|
|
|
const getBoardRichTextEditorText = (editorKey) => {
|
|
const quill = getBoardRichTextEditorInstance(editorKey)
|
|
if (quill) {
|
|
return quill.getText().trim()
|
|
}
|
|
|
|
const editorEl = document.getElementById(getBoardRichTextEditorId(editorKey))
|
|
if (!editorEl) {
|
|
return ""
|
|
}
|
|
|
|
return String(editorEl.textContent || "").trim()
|
|
}
|
|
|
|
const getBoardRichTextEditorHtml = (editorKey) => {
|
|
const quill = getBoardRichTextEditorInstance(editorKey)
|
|
if (quill) {
|
|
const rawHtml = quill.root.innerHTML.trim()
|
|
return quill.getText().trim() ? qSanitizeRichHtml(rawHtml) : ""
|
|
}
|
|
|
|
const editorEl = document.getElementById(getBoardRichTextEditorId(editorKey))
|
|
if (!editorEl) {
|
|
return ""
|
|
}
|
|
|
|
return qSanitizeRichHtml(editorEl.innerHTML.trim())
|
|
}
|
|
|
|
const setBoardRichTextEditorHtml = (editorKey, inputHtml) => {
|
|
const rawHtml = String(inputHtml ?? "")
|
|
const quill = getBoardRichTextEditorInstance(editorKey)
|
|
if (quill) {
|
|
if (!rawHtml.trim()) {
|
|
quill.setText("")
|
|
quill.setSelection(0, 0)
|
|
return
|
|
}
|
|
|
|
const sanitizedHtml = qSanitizeRichHtml(rawHtml)
|
|
const looksLikeHtml = /<\/?[a-z][\s\S]*>/i.test(rawHtml)
|
|
|
|
if (looksLikeHtml) {
|
|
quill.clipboard.dangerouslyPasteHTML(sanitizedHtml, "silent")
|
|
} else {
|
|
quill.setText(rawHtml)
|
|
}
|
|
quill.setSelection(0, 0)
|
|
return
|
|
}
|
|
|
|
const editorEl = document.getElementById(getBoardRichTextEditorId(editorKey))
|
|
if (editorEl) {
|
|
editorEl.innerHTML = /<\/?[a-z][\s\S]*>/i.test(rawHtml)
|
|
? qSanitizeRichHtml(rawHtml)
|
|
: qEscapeHtml(rawHtml)
|
|
}
|
|
}
|
|
|
|
const clearBoardRichTextEditor = (editorKey) => {
|
|
const quill = getBoardRichTextEditorInstance(editorKey)
|
|
if (quill) {
|
|
quill.setText("")
|
|
quill.setSelection(0, 0)
|
|
return
|
|
}
|
|
|
|
const editorEl = document.getElementById(getBoardRichTextEditorId(editorKey))
|
|
if (editorEl) {
|
|
editorEl.innerHTML = ""
|
|
}
|
|
}
|
|
|
|
const getBoardCommentEditorId = (cardIdentifier) =>
|
|
getBoardRichTextEditorId(`comment-${cardIdentifier}`)
|
|
|
|
const getBoardCommentComposerHtml = (cardIdentifier) =>
|
|
getBoardRichTextComposerHtml(
|
|
`comment-${cardIdentifier}`,
|
|
"richtext-compose comment-compose"
|
|
)
|
|
|
|
const ensureBoardCommentEditor = (
|
|
cardIdentifier,
|
|
placeholder = "Write a comment..."
|
|
) => ensureBoardRichTextEditor(`comment-${cardIdentifier}`, placeholder)
|
|
|
|
const getBoardCommentEditorInstance = (cardIdentifier) =>
|
|
getBoardRichTextEditorInstance(`comment-${cardIdentifier}`)
|
|
|
|
const getBoardCommentEditorText = (cardIdentifier) =>
|
|
getBoardRichTextEditorText(`comment-${cardIdentifier}`)
|
|
|
|
const getBoardCommentEditorHtml = (cardIdentifier) =>
|
|
getBoardRichTextEditorHtml(`comment-${cardIdentifier}`)
|
|
|
|
const clearBoardCommentEditor = (cardIdentifier) =>
|
|
clearBoardRichTextEditor(`comment-${cardIdentifier}`)
|
|
|
|
const boardCommentContentCache = new Map()
|
|
const boardCommentEditState = {
|
|
cardIdentifier: "",
|
|
commentIdentifier: "",
|
|
publisherName: "",
|
|
isEditing: false,
|
|
}
|
|
|
|
const rememberBoardCommentContent = (commentIdentifier, contentHtml = "") => {
|
|
const normalizedIdentifier = String(commentIdentifier || "").trim()
|
|
if (!normalizedIdentifier) {
|
|
return
|
|
}
|
|
|
|
boardCommentContentCache.set(normalizedIdentifier, String(contentHtml ?? ""))
|
|
}
|
|
|
|
const getBoardCommentContent = (commentIdentifier) => {
|
|
const normalizedIdentifier = String(commentIdentifier || "").trim()
|
|
if (!normalizedIdentifier) {
|
|
return ""
|
|
}
|
|
|
|
return boardCommentContentCache.get(normalizedIdentifier) || ""
|
|
}
|
|
|
|
const canCurrentUserEditPublishedComment = async (publishedName = "") => {
|
|
const currentName = String(userState?.accountName || "").trim()
|
|
const currentAddress = String(userState?.accountAddress || "").trim()
|
|
const normalizedPublishedName = String(publishedName || "").trim()
|
|
|
|
if (!normalizedPublishedName) {
|
|
return false
|
|
}
|
|
|
|
if (
|
|
currentName &&
|
|
currentName.toLowerCase() === normalizedPublishedName.toLowerCase()
|
|
) {
|
|
return true
|
|
}
|
|
|
|
if (
|
|
currentAddress &&
|
|
typeof fetchOwnerAddressFromNameCached === "function"
|
|
) {
|
|
const resolvedAddress = await fetchOwnerAddressFromNameCached(
|
|
normalizedPublishedName
|
|
)
|
|
return Boolean(resolvedAddress && resolvedAddress === currentAddress)
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
const updateBoardCommentActionBar = (cardIdentifier) => {
|
|
const normalizedCardIdentifier = String(cardIdentifier || "").trim()
|
|
if (!normalizedCardIdentifier) {
|
|
return
|
|
}
|
|
|
|
const submitButton = document.getElementById(
|
|
`comment-submit-button-${normalizedCardIdentifier}`
|
|
)
|
|
const cancelButton = document.getElementById(
|
|
`comment-cancel-button-${normalizedCardIdentifier}`
|
|
)
|
|
const statusEl = document.getElementById(
|
|
`comment-editor-status-${normalizedCardIdentifier}`
|
|
)
|
|
const isEditing =
|
|
boardCommentEditState.isEditing &&
|
|
boardCommentEditState.cardIdentifier === normalizedCardIdentifier
|
|
|
|
if (submitButton) {
|
|
submitButton.textContent = isEditing ? "Update Comment" : "Post Comment"
|
|
}
|
|
if (cancelButton) {
|
|
cancelButton.hidden = !isEditing
|
|
}
|
|
if (statusEl) {
|
|
statusEl.textContent = isEditing
|
|
? `Editing comment by ${boardCommentEditState.publisherName || "you"}.`
|
|
: ""
|
|
}
|
|
}
|
|
|
|
const clearBoardCommentEditState = async (cardIdentifier = "") => {
|
|
const normalizedCardIdentifier = String(cardIdentifier || "").trim()
|
|
const activeCardIdentifier =
|
|
normalizedCardIdentifier || boardCommentEditState.cardIdentifier
|
|
boardCommentEditState.cardIdentifier = ""
|
|
boardCommentEditState.commentIdentifier = ""
|
|
boardCommentEditState.publisherName = ""
|
|
boardCommentEditState.isEditing = false
|
|
|
|
if (activeCardIdentifier && typeof clearBoardCommentEditor === "function") {
|
|
clearBoardCommentEditor(activeCardIdentifier)
|
|
}
|
|
|
|
if (activeCardIdentifier) {
|
|
updateBoardCommentActionBar(activeCardIdentifier)
|
|
}
|
|
}
|
|
|
|
const getBoardCommentActionBarHtml = (
|
|
cardIdentifier,
|
|
submitHandlerName = "postComment"
|
|
) => `
|
|
<div class="comment-editor-actions">
|
|
<div
|
|
id="comment-editor-status-${qEscapeAttr(cardIdentifier)}"
|
|
class="comment-editor-status"
|
|
aria-live="polite"
|
|
></div>
|
|
<div class="comment-editor-buttons">
|
|
<button
|
|
type="button"
|
|
id="comment-submit-button-${qEscapeAttr(cardIdentifier)}"
|
|
class="comment-editor-submit"
|
|
onclick="${submitHandlerName}('${qEscapeAttr(cardIdentifier)}')"
|
|
>
|
|
Post Comment
|
|
</button>
|
|
<button
|
|
type="button"
|
|
id="comment-cancel-button-${qEscapeAttr(cardIdentifier)}"
|
|
class="comment-cancel-button"
|
|
onclick="clearBoardCommentEditState('${qEscapeAttr(cardIdentifier)}')"
|
|
hidden
|
|
>
|
|
Cancel Edit
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`
|
|
|
|
const buildBoardCommentEditButtonHtml = ({
|
|
cardIdentifier = "",
|
|
commentIdentifier = "",
|
|
publisherName = "",
|
|
} = {}) => {
|
|
const normalizedCardIdentifier = String(cardIdentifier || "").trim()
|
|
const normalizedCommentIdentifier = String(commentIdentifier || "").trim()
|
|
const normalizedPublisherName = String(publisherName || "").trim()
|
|
|
|
if (
|
|
!normalizedCardIdentifier ||
|
|
!normalizedCommentIdentifier ||
|
|
!normalizedPublisherName
|
|
) {
|
|
return ""
|
|
}
|
|
|
|
return `
|
|
<button
|
|
type="button"
|
|
class="comment-edit-button"
|
|
title="Edit comment"
|
|
aria-label="Edit comment"
|
|
data-card-identifier="${qEscapeAttr(normalizedCardIdentifier)}"
|
|
data-comment-identifier="${qEscapeAttr(normalizedCommentIdentifier)}"
|
|
data-comment-publisher="${qEscapeAttr(normalizedPublisherName)}"
|
|
onclick="openBoardCommentEditorFromElement(this, event)"
|
|
>
|
|
<span class="mobi-mbri-edit-2" aria-hidden="true"></span>
|
|
</button>
|
|
`
|
|
}
|
|
|
|
const openBoardCommentEditorFromElement = async (buttonEl, event) => {
|
|
if (event) {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
}
|
|
|
|
const cardIdentifier = String(buttonEl?.dataset?.cardIdentifier || "").trim()
|
|
const commentIdentifier = String(
|
|
buttonEl?.dataset?.commentIdentifier || ""
|
|
).trim()
|
|
const publisherName = String(buttonEl?.dataset?.commentPublisher || "").trim()
|
|
|
|
if (!cardIdentifier || !commentIdentifier || !publisherName) {
|
|
return false
|
|
}
|
|
|
|
const canEdit = await canCurrentUserEditPublishedComment(publisherName)
|
|
if (!canEdit) {
|
|
return false
|
|
}
|
|
|
|
boardCommentEditState.cardIdentifier = cardIdentifier
|
|
boardCommentEditState.commentIdentifier = commentIdentifier
|
|
boardCommentEditState.publisherName = publisherName
|
|
boardCommentEditState.isEditing = true
|
|
|
|
if (typeof ensureBoardCommentEditor === "function") {
|
|
ensureBoardCommentEditor(cardIdentifier, "Write a comment...")
|
|
}
|
|
|
|
if (typeof setBoardRichTextEditorHtml === "function") {
|
|
setBoardRichTextEditorHtml(
|
|
`comment-${cardIdentifier}`,
|
|
getBoardCommentContent(commentIdentifier)
|
|
)
|
|
}
|
|
|
|
updateBoardCommentActionBar(cardIdentifier)
|
|
|
|
const commentsSection = document.getElementById(
|
|
`comments-section-${cardIdentifier}`
|
|
)
|
|
if (commentsSection) {
|
|
commentsSection.style.display = "block"
|
|
commentsSection.scrollIntoView({
|
|
behavior: "smooth",
|
|
block: "nearest",
|
|
})
|
|
}
|
|
|
|
const editorInstance =
|
|
typeof getBoardCommentEditorInstance === "function"
|
|
? getBoardCommentEditorInstance(cardIdentifier)
|
|
: null
|
|
if (editorInstance?.focus) {
|
|
editorInstance.focus()
|
|
if (typeof editorInstance.getLength === "function") {
|
|
const selectionIndex = Math.max(0, editorInstance.getLength() - 1)
|
|
editorInstance.setSelection(selectionIndex, 0, "silent")
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
const qFetchBoardJson = async (path) => {
|
|
const trimmedBase = String(typeof baseUrl === "string" ? baseUrl : "").replace(
|
|
/\/$/,
|
|
""
|
|
)
|
|
const normalizedPath = String(path ?? "").startsWith("/")
|
|
? String(path ?? "")
|
|
: `/${String(path ?? "")}`
|
|
const response = await fetch(`${trimmedBase}${normalizedPath}`, {
|
|
method: "GET",
|
|
headers: {
|
|
Accept: "application/json",
|
|
},
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text().catch(() => "")
|
|
throw new Error(
|
|
`HTTP ${response.status}${errorText ? `: ${errorText}` : ""}`
|
|
)
|
|
}
|
|
|
|
return response.json()
|
|
}
|
|
|
|
const buildBoardAccountTriggerHtml = ({
|
|
name = "",
|
|
address = "",
|
|
label = "",
|
|
className = "board-account-trigger",
|
|
tagName = "button",
|
|
titlePrefix = "Open account details for",
|
|
extraTitle = "",
|
|
} = {}) => {
|
|
const rawName = String(name || label || address || "").trim()
|
|
const displayLabel = String(label || name || address || "Unknown").trim()
|
|
const safeLabel = qEscapeHtml(displayLabel)
|
|
const safeName = qEscapeAttr(rawName)
|
|
const safeAddress = qEscapeAttr(String(address || "").trim())
|
|
const safeTitle = qEscapeAttr(
|
|
extraTitle || `${titlePrefix} ${displayLabel || "account"}`
|
|
)
|
|
const safeAria = qEscapeAttr(
|
|
`${displayLabel || "Account"}. Open account details.`
|
|
)
|
|
const commonAttrs = `
|
|
class="${className}"
|
|
title="${safeTitle}"
|
|
aria-label="${safeAria}"
|
|
data-account-name="${safeName}"
|
|
data-account-address="${safeAddress}"
|
|
onclick="openBoardAccountInspectorFromElement(this, event)"
|
|
`
|
|
|
|
if (tagName === "span") {
|
|
return `
|
|
<span
|
|
${commonAttrs}
|
|
role="button"
|
|
tabindex="0"
|
|
onkeydown="if (event.key === 'Enter' || event.key === ' ') { openBoardAccountInspectorFromElement(this, event) }"
|
|
>${safeLabel}</span>
|
|
`
|
|
}
|
|
|
|
return `
|
|
<button
|
|
type="button"
|
|
${commonAttrs}
|
|
>${safeLabel}</button>
|
|
`
|
|
}
|
|
|
|
const getBoardNamesForAddress = async (address) => {
|
|
const normalizedAddress = String(address ?? "").trim()
|
|
if (!normalizedAddress) {
|
|
return []
|
|
}
|
|
|
|
if (boardAccountNamesCache.has(normalizedAddress)) {
|
|
return boardAccountNamesCache.get(normalizedAddress)
|
|
}
|
|
|
|
try {
|
|
const fetchNames = async (limit) =>
|
|
qFetchBoardJson(
|
|
`/names/address/${encodeURIComponent(normalizedAddress)}?limit=${limit}`
|
|
)
|
|
|
|
let data = null
|
|
try {
|
|
data = await fetchNames(0)
|
|
} catch (error) {
|
|
data = await fetchNames(20).catch(() => [])
|
|
}
|
|
|
|
const names = Array.isArray(data)
|
|
? data
|
|
.map((entry) => entry?.name)
|
|
.filter((name) => Boolean(String(name || "").trim()))
|
|
: []
|
|
boardAccountNamesCache.set(normalizedAddress, names)
|
|
return names
|
|
} catch (error) {
|
|
console.warn("Unable to fetch names for address:", normalizedAddress, error)
|
|
boardAccountNamesCache.set(normalizedAddress, [])
|
|
return []
|
|
}
|
|
}
|
|
|
|
const resolveBoardAccountIdentity = async (rawIdentity, rawAddress = "") => {
|
|
const qortalAddressPattern = /^Q[a-zA-Z0-9]{33}$/
|
|
const inputIdentity = String(rawIdentity ?? "").trim()
|
|
const addressHint = String(rawAddress ?? "").trim()
|
|
let resolvedAddress = ""
|
|
let resolvedName = ""
|
|
|
|
if (qortalAddressPattern.test(addressHint)) {
|
|
resolvedAddress = addressHint
|
|
}
|
|
|
|
if (!resolvedAddress && qortalAddressPattern.test(inputIdentity)) {
|
|
resolvedAddress = inputIdentity
|
|
}
|
|
|
|
if (!resolvedAddress && inputIdentity) {
|
|
const nameInfo =
|
|
typeof getNameInfoCached === "function"
|
|
? await getNameInfoCached(inputIdentity)
|
|
: typeof getNameInfo === "function"
|
|
? await getNameInfo(inputIdentity)
|
|
: null
|
|
if (nameInfo?.owner) {
|
|
resolvedAddress = nameInfo.owner
|
|
resolvedName = nameInfo.name || inputIdentity
|
|
}
|
|
}
|
|
|
|
if (!resolvedAddress && addressHint && !qortalAddressPattern.test(addressHint)) {
|
|
const maybeNameInfo =
|
|
typeof getNameInfoCached === "function"
|
|
? await getNameInfoCached(addressHint)
|
|
: typeof getNameInfo === "function"
|
|
? await getNameInfo(addressHint)
|
|
: null
|
|
if (maybeNameInfo?.owner) {
|
|
resolvedAddress = maybeNameInfo.owner
|
|
resolvedName = maybeNameInfo.name || addressHint
|
|
}
|
|
}
|
|
|
|
if (!resolvedAddress) {
|
|
return {
|
|
address: "",
|
|
displayName: inputIdentity || "Unknown",
|
|
resolvedName: "",
|
|
registeredNames: [],
|
|
inputIdentity,
|
|
}
|
|
}
|
|
|
|
const registeredNames = await getBoardNamesForAddress(resolvedAddress)
|
|
const inputLooksLikeAddress = qortalAddressPattern.test(inputIdentity)
|
|
const primaryName =
|
|
resolvedName || (!inputLooksLikeAddress ? inputIdentity : "") || registeredNames[0]
|
|
|
|
return {
|
|
address: resolvedAddress,
|
|
displayName: primaryName || resolvedAddress,
|
|
resolvedName: resolvedName || primaryName || "",
|
|
registeredNames,
|
|
inputIdentity,
|
|
}
|
|
}
|
|
|
|
const getBoardAccountSponsorshipInfo = async (address) => {
|
|
const normalizedAddress = String(address ?? "").trim()
|
|
if (!normalizedAddress) {
|
|
return {
|
|
data: null,
|
|
usedFallback: false,
|
|
}
|
|
}
|
|
|
|
if (boardAccountSponsorshipCache.has(normalizedAddress)) {
|
|
return boardAccountSponsorshipCache.get(normalizedAddress)
|
|
}
|
|
|
|
const fetchSponsorship = async (suffix = "") => {
|
|
try {
|
|
const data = await qFetchBoardJson(
|
|
`/addresses/sponsorship/${encodeURIComponent(normalizedAddress)}${suffix}`
|
|
)
|
|
if (
|
|
data &&
|
|
typeof data === "object" &&
|
|
!Array.isArray(data) &&
|
|
Object.keys(data).length > 0
|
|
) {
|
|
return data
|
|
}
|
|
return null
|
|
} catch (error) {
|
|
return null
|
|
}
|
|
}
|
|
|
|
const primary = await fetchSponsorship("")
|
|
if (primary) {
|
|
const result = {
|
|
data: primary,
|
|
usedFallback: false,
|
|
}
|
|
boardAccountSponsorshipCache.set(normalizedAddress, result)
|
|
return result
|
|
}
|
|
|
|
const fallback = await fetchSponsorship("/sponsor")
|
|
const result = {
|
|
data: fallback,
|
|
usedFallback: Boolean(fallback),
|
|
}
|
|
boardAccountSponsorshipCache.set(normalizedAddress, result)
|
|
return result
|
|
}
|
|
|
|
const getBoardAccountTransactions = async (
|
|
address,
|
|
offset = 0,
|
|
limit = 200
|
|
) => {
|
|
const normalizedAddress = String(address ?? "").trim()
|
|
if (!normalizedAddress) {
|
|
return []
|
|
}
|
|
|
|
const cacheKey = `${normalizedAddress}:${offset}:${limit}`
|
|
if (boardAccountTransactionPageCache.has(cacheKey)) {
|
|
return boardAccountTransactionPageCache.get(cacheKey)
|
|
}
|
|
|
|
if (typeof searchTransactions !== "function") {
|
|
return []
|
|
}
|
|
|
|
try {
|
|
const transactions = await searchTransactions({
|
|
address: normalizedAddress,
|
|
confirmationStatus: "BOTH",
|
|
limit,
|
|
reverse: true,
|
|
offset,
|
|
txTypes: [],
|
|
})
|
|
const page = Array.isArray(transactions) ? transactions : []
|
|
boardAccountTransactionPageCache.set(cacheKey, page)
|
|
return page
|
|
} catch (error) {
|
|
console.error("Unable to fetch account transactions:", error)
|
|
boardAccountTransactionPageCache.set(cacheKey, [])
|
|
return []
|
|
}
|
|
}
|
|
|
|
const buildBoardAccountTransactionCountsHtml = (transactions = []) => {
|
|
if (!Array.isArray(transactions) || transactions.length === 0) {
|
|
return `
|
|
<div class="account-tx-type-empty board-progress-muted">
|
|
No transaction history loaded yet.
|
|
</div>
|
|
`
|
|
}
|
|
|
|
const counts = new Map()
|
|
for (const tx of transactions) {
|
|
const type = String(tx?.type || "UNKNOWN").toUpperCase()
|
|
counts.set(type, (counts.get(type) || 0) + 1)
|
|
}
|
|
|
|
const sortedEntries = Array.from(counts.entries()).sort((a, b) => {
|
|
if (a[0] === "ARBITRARY") return -1
|
|
if (b[0] === "ARBITRARY") return 1
|
|
if (b[1] !== a[1]) return b[1] - a[1]
|
|
return a[0].localeCompare(b[0])
|
|
})
|
|
|
|
return `
|
|
<div class="account-tx-type-grid">
|
|
${sortedEntries
|
|
.map(
|
|
([type, count]) => `
|
|
<div class="account-tx-type-row ${
|
|
type === "ARBITRARY" ? "account-tx-type-row--arbitrary" : ""
|
|
}">
|
|
<span class="account-tx-type-name">${qEscapeHtml(type)}</span>
|
|
<span class="account-tx-type-count">${qEscapeHtml(String(count))}</span>
|
|
</div>
|
|
`
|
|
)
|
|
.join("")}
|
|
</div>
|
|
`
|
|
}
|
|
|
|
const buildBoardAccountTransactionMetaHtml = (tx = {}) => {
|
|
const metaEntries = [
|
|
["Type", tx.type],
|
|
["Timestamp", tx.timestamp ? new Date(tx.timestamp).toLocaleString() : ""],
|
|
["Name", tx.name],
|
|
["Identifier", tx.identifier],
|
|
["Creator", tx.creatorAddress],
|
|
["Service", tx.service],
|
|
["Method", tx.method],
|
|
["Approval", tx.approvalStatus],
|
|
["Block", tx.blockHeight],
|
|
["Fee", tx.fee],
|
|
["Size", tx.size],
|
|
["Group", tx.txGroupId],
|
|
["Compression", tx.compression],
|
|
["Data type", tx.dataType],
|
|
["Nonce", tx.nonce],
|
|
["Reference", tx.reference],
|
|
["Signature", tx.signature],
|
|
["Payments", Array.isArray(tx.payments) ? tx.payments.length : ""],
|
|
].filter(([, value]) => value !== undefined && value !== null && value !== "")
|
|
|
|
return `
|
|
<dl class="account-tx-meta-grid">
|
|
${metaEntries
|
|
.map(
|
|
([label, value]) => `
|
|
<div class="account-tx-meta-item">
|
|
<dt class="account-tx-meta-label">${qEscapeHtml(label)}</dt>
|
|
<dd class="account-tx-meta-value">${qEscapeHtml(String(value))}</dd>
|
|
</div>
|
|
`
|
|
)
|
|
.join("")}
|
|
</dl>
|
|
`
|
|
}
|
|
|
|
const buildBoardAccountTransactionEntryHtml = (tx = {}, index = 0) => {
|
|
const type = String(tx?.type || "UNKNOWN").toUpperCase()
|
|
const timestamp = tx?.timestamp
|
|
? new Date(tx.timestamp).toLocaleString()
|
|
: "Unknown time"
|
|
const identifier = String(
|
|
tx?.identifier || tx?.signature || tx?.reference || `tx-${index}`
|
|
)
|
|
const summaryTypeClass =
|
|
type === "ARBITRARY"
|
|
? "account-tx-summary-type account-tx-summary-type--arbitrary"
|
|
: "account-tx-summary-type"
|
|
|
|
return `
|
|
<details class="account-tx-item ${
|
|
type === "ARBITRARY" ? "account-tx-item--arbitrary" : ""
|
|
}">
|
|
<summary class="account-tx-summary">
|
|
<span class="${summaryTypeClass}">${qEscapeHtml(type)}</span>
|
|
<span class="account-tx-summary-time">${qEscapeHtml(timestamp)}</span>
|
|
<span class="account-tx-summary-id" title="${qEscapeAttr(
|
|
identifier
|
|
)}">${qEscapeHtml(identifier)}</span>
|
|
</summary>
|
|
<div class="account-tx-body">
|
|
${buildBoardAccountTransactionMetaHtml(tx)}
|
|
<pre class="account-tx-json">${qEscapeHtml(
|
|
JSON.stringify(tx, null, 2)
|
|
)}</pre>
|
|
</div>
|
|
</details>
|
|
`
|
|
}
|
|
|
|
const buildBoardAccountCardSection = (title, subtitle, bodyHtml) => `
|
|
<section class="account-modal-section">
|
|
<div class="account-section-heading">
|
|
<h3>${qEscapeHtml(title)}</h3>
|
|
${
|
|
subtitle
|
|
? `<p class="account-section-subtitle">${qEscapeHtml(subtitle)}</p>`
|
|
: ""
|
|
}
|
|
</div>
|
|
${bodyHtml}
|
|
</section>
|
|
`
|
|
|
|
const buildBoardAccountChipListHtml = (items = [], emptyLabel = "") => {
|
|
if (!Array.isArray(items) || items.length === 0) {
|
|
return emptyLabel
|
|
? `<div class="account-chip account-chip--empty">${qEscapeHtml(
|
|
emptyLabel
|
|
)}</div>`
|
|
: `<div class="account-chip account-chip--empty">No items found.</div>`
|
|
}
|
|
|
|
return `
|
|
<div class="account-chip-list">
|
|
${items
|
|
.map((item) =>
|
|
buildBoardAccountTriggerHtml({
|
|
name: item,
|
|
label: item,
|
|
className: "account-chip",
|
|
tagName: "button",
|
|
titlePrefix: "Open account details for",
|
|
})
|
|
)
|
|
.join("")}
|
|
</div>
|
|
`
|
|
}
|
|
|
|
const buildBoardAccountInspectorLoadingHtml = (title, subtitle = "") => `
|
|
<div class="account-modal-shell">
|
|
<div class="account-modal-header">
|
|
<div>
|
|
<p class="account-modal-kicker">Account Inspector</p>
|
|
<h2 class="account-modal-title">${qEscapeHtml(title)}</h2>
|
|
${
|
|
subtitle
|
|
? `<p class="account-modal-address">${qEscapeHtml(subtitle)}</p>`
|
|
: ""
|
|
}
|
|
</div>
|
|
</div>
|
|
<div class="account-modal-loading">
|
|
${getBoardLoadingHTML("Loading account details...")}
|
|
</div>
|
|
</div>
|
|
`
|
|
|
|
const buildBoardAccountInspectorHtml = () => {
|
|
const state = boardAccountInspectorState
|
|
const addressInfo = state.addressInfo || {}
|
|
const sponsorship = state.sponsorship?.data || null
|
|
const registeredNames = Array.isArray(state.names) ? state.names : []
|
|
const sponsorNames = Array.isArray(sponsorship?.names) ? sponsorship.names : []
|
|
const txLimit = Number(state.txLimit || 200)
|
|
const transactionCount = Array.isArray(state.transactions)
|
|
? state.transactions.length
|
|
: 0
|
|
const txTypeSummary = buildBoardAccountTransactionCountsHtml(
|
|
state.transactions
|
|
)
|
|
const txEntries = Array.isArray(state.transactions)
|
|
? state.transactions
|
|
.map((tx, index) => buildBoardAccountTransactionEntryHtml(tx, index))
|
|
.join("")
|
|
: ""
|
|
|
|
const identityStatsHtml = `
|
|
<div class="account-stat-grid">
|
|
<div class="account-stat-card">
|
|
<span class="account-stat-label">Address</span>
|
|
<span class="account-stat-value account-stat-value--mono">${qEscapeHtml(
|
|
state.address || "Unknown"
|
|
)}</span>
|
|
</div>
|
|
<div class="account-stat-card">
|
|
<span class="account-stat-label">Level</span>
|
|
<span class="account-stat-value">${qEscapeHtml(
|
|
String(addressInfo?.level ?? "n/a")
|
|
)}</span>
|
|
</div>
|
|
<div class="account-stat-card">
|
|
<span class="account-stat-label">Blocks minted</span>
|
|
<span class="account-stat-value">${qEscapeHtml(
|
|
String(addressInfo?.blocksMinted ?? 0)
|
|
)}</span>
|
|
</div>
|
|
<div class="account-stat-card">
|
|
<span class="account-stat-label">Adjustments</span>
|
|
<span class="account-stat-value">${qEscapeHtml(
|
|
String(addressInfo?.blocksMintedAdjustment ?? 0)
|
|
)}</span>
|
|
</div>
|
|
<div class="account-stat-card">
|
|
<span class="account-stat-label">Penalties</span>
|
|
<span class="account-stat-value">${qEscapeHtml(
|
|
String(addressInfo?.blocksMintedPenalty ?? 0)
|
|
)}</span>
|
|
</div>
|
|
<div class="account-stat-card">
|
|
<span class="account-stat-label">Transfer</span>
|
|
<span class="account-stat-value">${qEscapeHtml(
|
|
String(addressInfo?.transfer ?? "n/a")
|
|
)}</span>
|
|
</div>
|
|
</div>
|
|
`
|
|
|
|
const sponsorshipStatsHtml = sponsorship
|
|
? `
|
|
<div class="account-stat-grid">
|
|
<div class="account-stat-card">
|
|
<span class="account-stat-label">Sponsees</span>
|
|
<span class="account-stat-value">${qEscapeHtml(
|
|
String(sponsorship?.sponseeCount ?? 0)
|
|
)}</span>
|
|
</div>
|
|
<div class="account-stat-card">
|
|
<span class="account-stat-label">Non-registered</span>
|
|
<span class="account-stat-value">${qEscapeHtml(
|
|
String(sponsorship?.nonRegisteredCount ?? 0)
|
|
)}</span>
|
|
</div>
|
|
<div class="account-stat-card">
|
|
<span class="account-stat-label">Average balance</span>
|
|
<span class="account-stat-value">${qEscapeHtml(
|
|
String(sponsorship?.avgBalance ?? 0)
|
|
)}</span>
|
|
</div>
|
|
<div class="account-stat-card">
|
|
<span class="account-stat-label">Arbitrary publishes</span>
|
|
<span class="account-stat-value">${qEscapeHtml(
|
|
String(sponsorship?.arbitraryCount ?? 0)
|
|
)}</span>
|
|
</div>
|
|
<div class="account-stat-card">
|
|
<span class="account-stat-label">Transfer assets</span>
|
|
<span class="account-stat-value">${qEscapeHtml(
|
|
String(sponsorship?.transferAssetCount ?? 0)
|
|
)}</span>
|
|
</div>
|
|
<div class="account-stat-card">
|
|
<span class="account-stat-label">Transfer privs</span>
|
|
<span class="account-stat-value">${qEscapeHtml(
|
|
String(sponsorship?.transferPrivsCount ?? 0)
|
|
)}</span>
|
|
</div>
|
|
<div class="account-stat-card">
|
|
<span class="account-stat-label">Buys</span>
|
|
<span class="account-stat-value">${qEscapeHtml(
|
|
`${sponsorship?.buyCount ?? 0} / ${sponsorship?.buyAmount ?? 0}`
|
|
)}</span>
|
|
</div>
|
|
<div class="account-stat-card">
|
|
<span class="account-stat-label">Sells</span>
|
|
<span class="account-stat-value">${qEscapeHtml(
|
|
`${sponsorship?.sellCount ?? 0} / ${sponsorship?.sellAmount ?? 0}`
|
|
)}</span>
|
|
</div>
|
|
</div>
|
|
<p class="account-note">
|
|
NOTE - Sponsorship and Sponsee information is there for historic purposes and to help in decision-making. Qortal no longer makes use of the sponsorship method of the past, so the information is only relevant to see long-term past historic sponsor information.
|
|
</p>
|
|
`
|
|
: `
|
|
<p class="account-note">
|
|
No sponsorship profile was returned for this account. Transaction history is still shown below when available.
|
|
</p>
|
|
`
|
|
|
|
const registeredNamesHtml = buildBoardAccountChipListHtml(
|
|
registeredNames,
|
|
"No registered names were found for this address."
|
|
)
|
|
const sponsorNamesHtml = buildBoardAccountChipListHtml(
|
|
sponsorNames,
|
|
"No historic sponsee names were returned."
|
|
)
|
|
|
|
return `
|
|
<div class="account-modal-shell">
|
|
<div class="account-modal-header">
|
|
<div>
|
|
<p class="account-modal-kicker">Account Inspector</p>
|
|
<h2 class="account-modal-title">${qEscapeHtml(
|
|
state.displayName || state.address || "Account"
|
|
)}</h2>
|
|
<p class="account-modal-address">${qEscapeHtml(
|
|
state.address || "Unknown address"
|
|
)}</p>
|
|
</div>
|
|
</div>
|
|
|
|
${buildBoardAccountCardSection(
|
|
"Identity",
|
|
"Address-level details and registered names for this account.",
|
|
`
|
|
${identityStatsHtml}
|
|
<div class="account-chip-block">
|
|
<h4 class="account-chip-block-title">Registered names on this address</h4>
|
|
${registeredNamesHtml}
|
|
</div>
|
|
`
|
|
)}
|
|
|
|
${buildBoardAccountCardSection(
|
|
"Historic sponsorship",
|
|
state.sponsorship?.usedFallback
|
|
? "Fallback sponsor-side data was used because the direct sponsorship profile was empty."
|
|
: "Historic sponsorship data and sponsee totals, useful for long-term context.",
|
|
`
|
|
${sponsorshipStatsHtml}
|
|
<div class="account-chip-block">
|
|
<h4 class="account-chip-block-title">Historic sponsee names</h4>
|
|
${sponsorNamesHtml}
|
|
</div>
|
|
`
|
|
)}
|
|
|
|
${buildBoardAccountCardSection(
|
|
"Recent TX History",
|
|
`Initially loaded transaction count: ${txLimit}. More can be loaded below. The ARBITRARY type is highlighted because it is the main QDN publish signal we care about here.`,
|
|
`
|
|
<div id="account-transaction-summary">
|
|
${txTypeSummary}
|
|
</div>
|
|
${
|
|
transactionCount > 0
|
|
? `<div id="account-transactions-list" class="account-tx-list">${txEntries}</div>`
|
|
: `<div id="account-transactions-list" class="account-tx-empty">No transactions have been loaded for this account yet.</div>`
|
|
}
|
|
${
|
|
state.txHasMore
|
|
? `
|
|
<div class="account-tx-load-row">
|
|
<button
|
|
type="button"
|
|
id="account-load-more-button"
|
|
class="account-load-more-button"
|
|
onclick="loadMoreBoardAccountTransactions()"
|
|
${state.txLoadingMore ? "disabled" : ""}
|
|
>
|
|
${state.txLoadingMore ? "Loading more..." : "Fetch more"}
|
|
</button>
|
|
</div>
|
|
`
|
|
: ""
|
|
}
|
|
`
|
|
)}
|
|
</div>
|
|
`
|
|
}
|
|
|
|
const ensureBoardAccountInspectorModal = () => {
|
|
if (typeof createModal === "function") {
|
|
createModal("account")
|
|
}
|
|
}
|
|
|
|
const openBoardAccountInspectorFromElement = async (buttonEl, event) => {
|
|
if (event) {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
}
|
|
|
|
const identity =
|
|
buttonEl?.dataset?.accountName ||
|
|
buttonEl?.dataset?.accountIdentity ||
|
|
""
|
|
const address = buttonEl?.dataset?.accountAddress || ""
|
|
await openBoardAccountInspector(identity, address)
|
|
}
|
|
|
|
const openBoardAccountInspector = async (rawIdentity, rawAddress = "") => {
|
|
ensureBoardAccountInspectorModal()
|
|
|
|
const modal = document.getElementById("account-modal")
|
|
const modalContent = document.getElementById("account-modalContent")
|
|
if (!modal || !modalContent) {
|
|
return
|
|
}
|
|
|
|
const requestId = ++boardAccountInspectorState.requestId
|
|
const initialLabel = String(rawIdentity || rawAddress || "Account").trim()
|
|
modal.style.display = "block"
|
|
modalContent.innerHTML = buildBoardAccountInspectorLoadingHtml(
|
|
initialLabel,
|
|
"Loading account data..."
|
|
)
|
|
|
|
const resolvedIdentity = await resolveBoardAccountIdentity(
|
|
rawIdentity,
|
|
rawAddress
|
|
)
|
|
if (requestId !== boardAccountInspectorState.requestId) {
|
|
return
|
|
}
|
|
|
|
if (!resolvedIdentity.address) {
|
|
modalContent.innerHTML = `
|
|
<div class="account-modal-shell">
|
|
<div class="account-modal-header">
|
|
<div>
|
|
<p class="account-modal-kicker">Account Inspector</p>
|
|
<h2 class="account-modal-title">${qEscapeHtml(
|
|
resolvedIdentity.displayName || initialLabel || "Account"
|
|
)}</h2>
|
|
</div>
|
|
</div>
|
|
<div class="account-note">
|
|
This label does not resolve to a Qortal account address, so there is nothing to inspect yet.
|
|
</div>
|
|
</div>
|
|
`
|
|
return
|
|
}
|
|
|
|
const txLimit = boardAccountInspectorState.txLimit || 200
|
|
const [addressInfo, names, sponsorship, transactions] = await Promise.all([
|
|
(typeof getAddressInfoCached === "function"
|
|
? getAddressInfoCached(resolvedIdentity.address)
|
|
: getAddressInfo(resolvedIdentity.address)
|
|
).catch(() => null),
|
|
getBoardNamesForAddress(resolvedIdentity.address).catch(() => []),
|
|
getBoardAccountSponsorshipInfo(resolvedIdentity.address).catch(() => ({
|
|
data: null,
|
|
usedFallback: false,
|
|
})),
|
|
getBoardAccountTransactions(resolvedIdentity.address, 0, txLimit).catch(
|
|
() => []
|
|
),
|
|
])
|
|
|
|
if (requestId !== boardAccountInspectorState.requestId) {
|
|
return
|
|
}
|
|
|
|
boardAccountInspectorState.address = resolvedIdentity.address
|
|
boardAccountInspectorState.displayName =
|
|
resolvedIdentity.displayName || resolvedIdentity.address
|
|
boardAccountInspectorState.resolvedName = resolvedIdentity.resolvedName || ""
|
|
boardAccountInspectorState.addressInfo = addressInfo || null
|
|
boardAccountInspectorState.names = names || []
|
|
boardAccountInspectorState.sponsorship = sponsorship || {
|
|
data: null,
|
|
usedFallback: false,
|
|
}
|
|
boardAccountInspectorState.transactions = Array.isArray(transactions)
|
|
? transactions
|
|
: []
|
|
boardAccountInspectorState.txOffset = 0
|
|
boardAccountInspectorState.txHasMore =
|
|
Array.isArray(transactions) && transactions.length === txLimit
|
|
boardAccountInspectorState.txLoadingMore = false
|
|
|
|
modalContent.innerHTML = buildBoardAccountInspectorHtml()
|
|
modalContent.scrollTop = 0
|
|
}
|
|
|
|
const updateBoardAccountInspectorTransactionSection = () => {
|
|
const summaryEl = document.getElementById("account-transaction-summary")
|
|
const loadButton = document.getElementById("account-load-more-button")
|
|
|
|
if (summaryEl) {
|
|
summaryEl.innerHTML = buildBoardAccountTransactionCountsHtml(
|
|
boardAccountInspectorState.transactions
|
|
)
|
|
}
|
|
|
|
if (loadButton) {
|
|
const loadRow = loadButton.closest(".account-tx-load-row")
|
|
if (!boardAccountInspectorState.txHasMore) {
|
|
if (loadRow) {
|
|
loadRow.remove()
|
|
} else {
|
|
loadButton.remove()
|
|
}
|
|
return
|
|
}
|
|
|
|
loadButton.textContent = boardAccountInspectorState.txLoadingMore
|
|
? "Loading more..."
|
|
: "Fetch more"
|
|
loadButton.disabled = Boolean(boardAccountInspectorState.txLoadingMore)
|
|
}
|
|
}
|
|
|
|
const loadMoreBoardAccountTransactions = async () => {
|
|
if (
|
|
boardAccountInspectorState.txLoadingMore ||
|
|
!boardAccountInspectorState.txHasMore ||
|
|
!boardAccountInspectorState.address
|
|
) {
|
|
return
|
|
}
|
|
|
|
const requestId = boardAccountInspectorState.requestId
|
|
const loadButton = document.getElementById("account-load-more-button")
|
|
boardAccountInspectorState.txLoadingMore = true
|
|
if (loadButton) {
|
|
loadButton.disabled = true
|
|
loadButton.textContent = "Loading more..."
|
|
}
|
|
|
|
const nextOffset = boardAccountInspectorState.transactions.length
|
|
const nextPage = await getBoardAccountTransactions(
|
|
boardAccountInspectorState.address,
|
|
nextOffset,
|
|
boardAccountInspectorState.txLimit
|
|
)
|
|
|
|
if (requestId !== boardAccountInspectorState.requestId) {
|
|
return
|
|
}
|
|
|
|
boardAccountInspectorState.txLoadingMore = false
|
|
if (Array.isArray(nextPage) && nextPage.length > 0) {
|
|
boardAccountInspectorState.transactions = [
|
|
...boardAccountInspectorState.transactions,
|
|
...nextPage,
|
|
]
|
|
const listEl = document.getElementById("account-transactions-list")
|
|
if (listEl) {
|
|
listEl.insertAdjacentHTML(
|
|
"beforeend",
|
|
nextPage
|
|
.map((tx, index) =>
|
|
buildBoardAccountTransactionEntryHtml(tx, nextOffset + index)
|
|
)
|
|
.join("")
|
|
)
|
|
}
|
|
}
|
|
boardAccountInspectorState.txOffset =
|
|
boardAccountInspectorState.transactions.length
|
|
boardAccountInspectorState.txHasMore =
|
|
Array.isArray(nextPage) &&
|
|
nextPage.length === boardAccountInspectorState.txLimit
|
|
|
|
updateBoardAccountInspectorTransactionSection()
|
|
}
|
|
|
|
const canCurrentUserEditPublishedCard = async (
|
|
publishedName,
|
|
publishedAddress = ""
|
|
) => {
|
|
const currentName = String(userState?.accountName || "").trim()
|
|
const currentAddress = String(userState?.accountAddress || "").trim()
|
|
const normalizedPublishedName = String(publishedName || "").trim()
|
|
const normalizedPublishedAddress = String(publishedAddress || "").trim()
|
|
|
|
if (
|
|
currentAddress &&
|
|
normalizedPublishedAddress &&
|
|
currentAddress === normalizedPublishedAddress
|
|
) {
|
|
return true
|
|
}
|
|
|
|
if (
|
|
currentName &&
|
|
normalizedPublishedName &&
|
|
currentName.toLowerCase() === normalizedPublishedName.toLowerCase()
|
|
) {
|
|
return true
|
|
}
|
|
|
|
if (
|
|
normalizedPublishedName &&
|
|
typeof fetchOwnerAddressFromNameCached === "function" &&
|
|
currentAddress
|
|
) {
|
|
const resolvedAddress = await fetchOwnerAddressFromNameCached(
|
|
normalizedPublishedName
|
|
)
|
|
return Boolean(resolvedAddress && resolvedAddress === currentAddress)
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
const scrollBoardCommentsToBottom = async (cardIdentifier) => {
|
|
const commentsContainer = document.getElementById(
|
|
`comments-container-${cardIdentifier}`
|
|
)
|
|
if (!commentsContainer) {
|
|
return false
|
|
}
|
|
|
|
await new Promise((resolve) => {
|
|
if (typeof requestAnimationFrame === "function") {
|
|
requestAnimationFrame(() => requestAnimationFrame(resolve))
|
|
return
|
|
}
|
|
|
|
setTimeout(resolve, 0)
|
|
})
|
|
|
|
commentsContainer.scrollTop = commentsContainer.scrollHeight
|
|
return true
|
|
}
|
|
|
|
const scrollBoardCommentIntoView = async (cardIdentifier, commentIdentifier) => {
|
|
const normalizedCardIdentifier = String(cardIdentifier || "").trim()
|
|
const normalizedCommentIdentifier = String(commentIdentifier || "").trim()
|
|
if (!normalizedCardIdentifier || !normalizedCommentIdentifier) {
|
|
return false
|
|
}
|
|
|
|
const commentsContainer = document.getElementById(
|
|
`comments-container-${normalizedCardIdentifier}`
|
|
)
|
|
if (!commentsContainer) {
|
|
return false
|
|
}
|
|
|
|
await new Promise((resolve) => {
|
|
if (typeof requestAnimationFrame === "function") {
|
|
requestAnimationFrame(() => requestAnimationFrame(resolve))
|
|
return
|
|
}
|
|
|
|
setTimeout(resolve, 0)
|
|
})
|
|
|
|
const safeSelector = normalizedCommentIdentifier.replace(/"/g, '\\"')
|
|
const commentEl = commentsContainer.querySelector(
|
|
`[data-comment-identifier="${safeSelector}"]`
|
|
)
|
|
if (commentEl?.scrollIntoView) {
|
|
commentEl.scrollIntoView({
|
|
behavior: "smooth",
|
|
block: "center",
|
|
})
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
const getBoardAccountLevel = async (nameOrAddress) => {
|
|
const rawIdentity = String(nameOrAddress ?? "").trim()
|
|
if (!rawIdentity) {
|
|
return null
|
|
}
|
|
|
|
if (boardIdentityLevelCache.has(rawIdentity)) {
|
|
return boardIdentityLevelCache.get(rawIdentity)
|
|
}
|
|
|
|
const qortalAddressPattern = /^Q[A-Za-z0-9]{33}$/
|
|
const resolvedAddress = qortalAddressPattern.test(rawIdentity)
|
|
? rawIdentity
|
|
: typeof fetchOwnerAddressFromNameCached === "function"
|
|
? await fetchOwnerAddressFromNameCached(rawIdentity)
|
|
: null
|
|
|
|
if (!resolvedAddress) {
|
|
boardIdentityLevelCache.set(rawIdentity, null)
|
|
return null
|
|
}
|
|
|
|
try {
|
|
const addressInfo =
|
|
typeof getAddressInfoCached === "function"
|
|
? await getAddressInfoCached(resolvedAddress)
|
|
: await getAddressInfo(resolvedAddress)
|
|
const level = Number(addressInfo?.level)
|
|
const nextLevel = Number.isFinite(level) ? level : null
|
|
boardIdentityLevelCache.set(rawIdentity, nextLevel)
|
|
return nextLevel
|
|
} catch (error) {
|
|
console.warn("Unable to resolve account level:", rawIdentity, error)
|
|
boardIdentityLevelCache.set(rawIdentity, null)
|
|
return null
|
|
}
|
|
}
|
|
|
|
// Kakashi Note: Shared button handlers read escaped data-* values to avoid passing untrusted strings through inline JS.
|
|
// Use data-link on buttons and pass only element refs to handlers to prevent inline JS injection.
|
|
const openLinksModalFromButton = (buttonEl) => {
|
|
if (!buttonEl) return
|
|
const rawLink = buttonEl.dataset?.link || ""
|
|
if (typeof openLinksModal === "function") {
|
|
openLinksModal(rawLink)
|
|
}
|
|
}
|
|
|
|
const openLinkDisplayModalFromButton = (buttonEl) => {
|
|
if (!buttonEl) return
|
|
const rawLink = buttonEl.dataset?.link || ""
|
|
if (typeof openLinkDisplayModal === "function") {
|
|
openLinkDisplayModal(rawLink)
|
|
}
|
|
}
|
|
|
|
const getBoardLoadingHTML = (message = "Loading cards...") => {
|
|
const safeMessage = qEscapeHtml(message)
|
|
return `
|
|
<div class="board-loading" role="status" aria-live="polite" aria-busy="true">
|
|
<div class="board-loading-spinner" aria-hidden="true"></div>
|
|
<p>${safeMessage}</p>
|
|
</div>
|
|
`
|
|
}
|
|
|
|
const getBoardInlineLoadingHTML = (message = "Loading cards...") => {
|
|
const safeMessage = qEscapeHtml(message)
|
|
return `
|
|
<span class="board-loading-inline" role="status" aria-live="polite" aria-busy="true">
|
|
<span class="board-loading-spinner board-loading-spinner-inline" aria-hidden="true"></span>
|
|
<span>${safeMessage}</span>
|
|
</span>
|
|
`
|
|
}
|
|
|
|
const fetchBlockList = async () => {
|
|
try {
|
|
// searchSimple to find all resources for that identifier
|
|
const results = await searchSimple(
|
|
"BLOG_POST",
|
|
blockedNamesIdentifier, // identifier
|
|
"", // name
|
|
0, // limit=0 => no limit
|
|
0, // offset
|
|
"", // room
|
|
true, // reverse => newest first or oldest first?
|
|
true // prefixOnly => depends on whether you want partial matches
|
|
)
|
|
|
|
if (!results || !Array.isArray(results) || results.length === 0) {
|
|
console.warn("No blockList resources found via searchSimple.")
|
|
return []
|
|
}
|
|
// We must filter out resources not published by an admin
|
|
const adminGroupMembers = await fetchAllAdminGroupsMembers()
|
|
const adminAddresses = adminGroupMembers.map((m) => m.member)
|
|
// The result objects from searchSimple have shape: { name, identifier, service, created, updated, ... }
|
|
// We want only those where 'name' is an admin address's name, or the 'address' is in adminAddresses
|
|
// But searchSimple doesn't give you the publisher address directly, only the name.
|
|
// So we must check if the name belongs to an admin address
|
|
const validAdminResults = []
|
|
for (const r of results) {
|
|
try {
|
|
// fetchOwnerAddressFromName or getNameInfo to see if r.name resolves to one of the admin addresses
|
|
const nameInfo = await getNameInfo(r.name)
|
|
if (!nameInfo || !nameInfo.owner) {
|
|
continue
|
|
}
|
|
if (adminAddresses.includes(nameInfo.owner)) {
|
|
validAdminResults.push(r)
|
|
}
|
|
} catch (err) {
|
|
console.warn(
|
|
`Skipping result from ${r.name} - cannot confirm admin address`,
|
|
err
|
|
)
|
|
}
|
|
}
|
|
|
|
if (validAdminResults.length === 0) {
|
|
console.warn("No valid admin-published blockList resource found.")
|
|
return []
|
|
}
|
|
// pick the newest result among validAdminResults
|
|
// Usually you check r.updated or r.created
|
|
validAdminResults.sort((a, b) => {
|
|
const tA = a.updated || a.created || 0
|
|
const tB = b.updated || b.created || 0
|
|
return tB - tA // newest first
|
|
})
|
|
const newestValid = validAdminResults[0]
|
|
|
|
// fetch the actual data
|
|
const resourceData = await qortalRequest({
|
|
action: "FETCH_QDN_RESOURCE",
|
|
name: newestValid.name,
|
|
service: newestValid.service, // "BLOG_POST"
|
|
identifier: newestValid.identifier,
|
|
})
|
|
if (!resourceData) {
|
|
console.warn("Fetched resource data is null/empty.")
|
|
return []
|
|
}
|
|
|
|
// parse resourceData
|
|
// If it's a string containing base64 JSON
|
|
let blockedList
|
|
if (typeof resourceData === "string") {
|
|
// decode base64 => parse JSON
|
|
const decoded = atob(resourceData)
|
|
blockedList = JSON.parse(decoded)
|
|
} else if (Array.isArray(resourceData)) {
|
|
// the resource is already an array
|
|
blockedList = resourceData
|
|
} else {
|
|
// maybe resourceData has data64 property or something else
|
|
// adapt if needed
|
|
console.warn("Unexpected blockList format. Could not parse.")
|
|
return []
|
|
}
|
|
|
|
if (!Array.isArray(blockedList)) {
|
|
console.warn("Block list is not an array:", blockedList)
|
|
return []
|
|
}
|
|
console.log("Newest block list loaded:", blockedList)
|
|
return blockedList
|
|
} catch (err) {
|
|
console.error("Failed to load block list:", err)
|
|
return []
|
|
}
|
|
}
|
|
|
|
const publishBlockList = async (blockedNames) => {
|
|
if (!Array.isArray(blockedNames)) {
|
|
console.warn("publishBlockList requires an array")
|
|
return
|
|
}
|
|
try {
|
|
const jsonStr = JSON.stringify(blockedNames)
|
|
const data64 = btoa(jsonStr)
|
|
// Publish
|
|
await qortalRequest({
|
|
action: "PUBLISH_QDN_RESOURCE",
|
|
name: `${userState.accountName}`, // The name under which your admin can publish
|
|
service: "BLOG_POST",
|
|
identifier: `${blockedNamesIdentifier}`,
|
|
data64,
|
|
})
|
|
alert("Block list published successfully!")
|
|
} catch (err) {
|
|
console.error("Failed to publish block list:", err)
|
|
alert("Error publishing block list.")
|
|
}
|
|
}
|
|
|
|
// Function for obtaining all kick/ban transaction data, and separating it into PENDING and NON.
|
|
const fetchAllKickBanTxData = async () => {
|
|
const kickTxType = "GROUP_KICK"
|
|
const banTxType = "GROUP_BAN"
|
|
|
|
const allKickTx = await searchTransactions({
|
|
txTypes: [kickTxType],
|
|
confirmationStatus: "CONFIRMED",
|
|
limit: 0,
|
|
reverse: true,
|
|
offset: 0,
|
|
startBlock: 1990000,
|
|
blockLimit: 0,
|
|
txGroupId: 0,
|
|
})
|
|
|
|
const allBanTx = await searchTransactions({
|
|
txTypes: [banTxType],
|
|
confirmationStatus: "CONFIRMED",
|
|
limit: 0,
|
|
reverse: true,
|
|
offset: 0,
|
|
startBlock: 1990000,
|
|
blockLimit: 0,
|
|
txGroupId: 0,
|
|
})
|
|
|
|
const { finalTx: finalKickTxs, pendingTx: pendingKickTxs } =
|
|
partitionTransactions(allKickTx)
|
|
const { finalTx: finalBanTxs, pendingTx: pendingBanTxs } =
|
|
partitionTransactions(allBanTx)
|
|
|
|
// We are going to keep all transactions in order to filter more accurately for display purposes.
|
|
console.log("Final kickTxs:", finalKickTxs)
|
|
console.log("Pending kickTxs:", pendingKickTxs)
|
|
console.log("Final banTxs:", finalBanTxs)
|
|
console.log("Pending banTxs:", pendingBanTxs)
|
|
|
|
return {
|
|
finalKickTxs,
|
|
pendingKickTxs,
|
|
finalBanTxs,
|
|
pendingBanTxs,
|
|
}
|
|
}
|
|
|
|
const partitionTransactions = (txSearchResults) => {
|
|
const finalTx = []
|
|
const pendingTx = []
|
|
|
|
for (const tx of txSearchResults) {
|
|
if (tx.approvalStatus === "PENDING") {
|
|
pendingTx.push(tx)
|
|
} else {
|
|
finalTx.push(tx)
|
|
}
|
|
}
|
|
|
|
return { finalTx, pendingTx }
|
|
}
|
|
|
|
const fetchAllInviteTransactions = async () => {
|
|
const inviteTxType = "GROUP_INVITE"
|
|
|
|
const allInviteTx = await searchTransactions({
|
|
txTypes: [inviteTxType],
|
|
confirmationStatus: "CONFIRMED",
|
|
limit: 0,
|
|
reverse: true,
|
|
offset: 0,
|
|
startBlock: 1990000,
|
|
blockLimit: 0,
|
|
txGroupId: 0,
|
|
})
|
|
|
|
const { finalTx: finalInviteTxs, pendingTx: pendingInviteTxs } =
|
|
partitionTransactions(allInviteTx)
|
|
|
|
console.log("Final InviteTxs:", finalInviteTxs)
|
|
console.log("Pending InviteTxs:", pendingInviteTxs)
|
|
|
|
return {
|
|
finalInviteTxs,
|
|
pendingInviteTxs,
|
|
}
|
|
}
|
|
|
|
const findPendingApprovalsForTxSignature = async (
|
|
txSignature,
|
|
txType = "GROUP_APPROVAL",
|
|
limit = 0,
|
|
offset = 0
|
|
) => {
|
|
const pendingTxs = await searchPendingTransactions(limit, offset)
|
|
|
|
// Filter only the relevant GROUP_APPROVAL TX referencing txSignature
|
|
const approvals = pendingTxs.filter(
|
|
(tx) => tx.type === txType && tx.pendingSignature === txSignature
|
|
)
|
|
console.log(`approvals found:`, approvals)
|
|
return approvals
|
|
}
|