mirror of
https://github.com/Qortal/qortal.git
synced 2026-04-29 04:59:22 +00:00
1144 lines
36 KiB
JavaScript
1144 lines
36 KiB
JavaScript
let customQDNHistoryPaths = []; // Array to track visited paths
|
|
let currentIndex = -1; // Index to track the current position in the history
|
|
let isManualNavigation = true; // Flag to control when to add new paths. set to false when navigating through a back/forward call
|
|
|
|
function resetVariables() {
|
|
let customQDNHistoryPaths = [];
|
|
let currentIndex = -1;
|
|
let isManualNavigation = true;
|
|
}
|
|
|
|
function getNameAfterService(url) {
|
|
try {
|
|
const parsedUrl = new URL(url);
|
|
const pathParts = parsedUrl.pathname.split("/");
|
|
|
|
// Find the index of "WEBSITE" or "APP" and get the next part
|
|
const serviceIndex = pathParts.findIndex(
|
|
(part) => part === "WEBSITE" || part === "APP"
|
|
);
|
|
|
|
if (serviceIndex !== -1 && pathParts[serviceIndex + 1]) {
|
|
return pathParts[serviceIndex + 1];
|
|
} else {
|
|
return null; // Return null if "WEBSITE" or "APP" is not found or has no following part
|
|
}
|
|
} catch (error) {
|
|
console.error("Invalid URL provided:", error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parseUrl(url) {
|
|
try {
|
|
const parsedUrl = new URL(url);
|
|
|
|
// Check if isManualNavigation query exists and is set to "false"
|
|
const isManual = parsedUrl.searchParams.get("isManualNavigation");
|
|
|
|
if (isManual !== null && isManual == "false") {
|
|
isManualNavigation = false;
|
|
// Optional: handle this condition if needed (e.g., return or adjust the response)
|
|
}
|
|
|
|
// Remove theme, identifier, and time queries if they exist
|
|
parsedUrl.searchParams.delete("theme");
|
|
parsedUrl.searchParams.delete("lang");
|
|
parsedUrl.searchParams.delete("identifier");
|
|
parsedUrl.searchParams.delete("time");
|
|
parsedUrl.searchParams.delete("isManualNavigation");
|
|
// Extract the pathname and remove the prefix if it matches "render/APP" or "render/WEBSITE"
|
|
const path = parsedUrl.pathname.replace(
|
|
/^\/render\/(APP|WEBSITE)\/[^/]+/,
|
|
""
|
|
);
|
|
|
|
// Combine the path with remaining query params (if any)
|
|
return path + parsedUrl.search;
|
|
} catch (error) {
|
|
console.error("Invalid URL provided:", error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Tell the client to open a new tab. Done when an app is linking to another app
|
|
function openNewTab(data) {
|
|
window.parent.postMessage(
|
|
{
|
|
action: "SET_TAB",
|
|
requestedHandler: "UI",
|
|
payload: data,
|
|
},
|
|
"*"
|
|
);
|
|
}
|
|
// sends navigation information to the client in order to manage back/forward navigation
|
|
function sendNavigationInfoToParent(isDOMContentLoaded) {
|
|
window.parent.postMessage(
|
|
{
|
|
action: "NAVIGATION_HISTORY",
|
|
requestedHandler: "UI",
|
|
payload: {
|
|
customQDNHistoryPaths,
|
|
currentIndex,
|
|
isDOMContentLoaded: isDOMContentLoaded ? true : false,
|
|
},
|
|
},
|
|
"*"
|
|
);
|
|
}
|
|
|
|
function handleQDNResourceDisplayed(pathurl, isDOMContentLoaded) {
|
|
// make sure that an empty string the root path
|
|
if (pathurl?.startsWith("/render/hash/")) return;
|
|
const path = pathurl || "/";
|
|
if (!isManualNavigation) {
|
|
isManualNavigation = true;
|
|
// If the navigation is automatic (back/forward), do not add new entries
|
|
return;
|
|
}
|
|
|
|
// If it's a new path, add it to the history array and adjust the index
|
|
if (customQDNHistoryPaths[currentIndex] !== path) {
|
|
customQDNHistoryPaths = customQDNHistoryPaths.slice(0, currentIndex + 1);
|
|
|
|
// Add the new path and move the index to the new position
|
|
customQDNHistoryPaths.push(path);
|
|
currentIndex = customQDNHistoryPaths.length - 1;
|
|
sendNavigationInfoToParent(isDOMContentLoaded);
|
|
} else {
|
|
currentIndex = customQDNHistoryPaths.length - 1;
|
|
sendNavigationInfoToParent(isDOMContentLoaded);
|
|
}
|
|
|
|
// Reset isManualNavigation after handling
|
|
isManualNavigation = true;
|
|
}
|
|
|
|
// Request deduplication cache
|
|
const requestCache = new Map();
|
|
const REQUEST_CACHE_TTL = 5000; // 5 seconds
|
|
|
|
// Pending request tracking for cleanup
|
|
const pendingMessageChannels = new Map();
|
|
|
|
// Request queue to limit concurrent requests
|
|
const MAX_CONCURRENT_REQUESTS = 30;
|
|
let activeRequestCount = 0;
|
|
const requestQueue = [];
|
|
|
|
// Debug logging (set to true to enable)
|
|
const DEBUG_REQUESTS = false;
|
|
|
|
function debugLog(...args) {
|
|
if (DEBUG_REQUESTS) {
|
|
console.log("[q-apps.js]", ...args);
|
|
}
|
|
}
|
|
|
|
function httpGet(url) {
|
|
// Check cache first
|
|
const cached = requestCache.get(url);
|
|
if (cached && Date.now() - cached.timestamp < REQUEST_CACHE_TTL) {
|
|
return cached.data;
|
|
}
|
|
|
|
var request = new XMLHttpRequest();
|
|
request.open("GET", url, false);
|
|
request.send(null);
|
|
|
|
// Cache the response
|
|
requestCache.set(url, {
|
|
data: request.responseText,
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
return request.responseText;
|
|
}
|
|
|
|
// Async request deduplication
|
|
const pendingAsyncRequests = new Map();
|
|
|
|
function httpGetAsyncWithEvent(event, url) {
|
|
// Check if same request is already pending
|
|
if (pendingAsyncRequests.has(url)) {
|
|
// Attach to existing request instead of creating new one
|
|
pendingAsyncRequests
|
|
.get(url)
|
|
.then((responseText) => {
|
|
handleResponse(event, responseText);
|
|
})
|
|
.catch((error) => {
|
|
let res = {};
|
|
res.error = error;
|
|
handleResponse(event, JSON.stringify(res));
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Create new request and cache the promise
|
|
const requestPromise = fetch(url)
|
|
.then((response) => response.text())
|
|
.then((responseText) => {
|
|
// Remove from pending cache
|
|
pendingAsyncRequests.delete(url);
|
|
|
|
if (responseText == null) {
|
|
// Pass to parent (UI), in case they can fulfil this request
|
|
event.data.requestedHandler = "UI";
|
|
parent.postMessage(event.data, "*", [event.ports[0]]);
|
|
return null;
|
|
}
|
|
|
|
return responseText;
|
|
});
|
|
|
|
// Store the promise for deduplication
|
|
pendingAsyncRequests.set(url, requestPromise);
|
|
|
|
requestPromise
|
|
.then((responseText) => {
|
|
if (responseText !== null) {
|
|
handleResponse(event, responseText);
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
// Remove from pending cache on error
|
|
pendingAsyncRequests.delete(url);
|
|
let res = {};
|
|
res.error = error;
|
|
handleResponse(event, JSON.stringify(res));
|
|
});
|
|
}
|
|
|
|
function handleResponse(event, response) {
|
|
if (event == null) {
|
|
return;
|
|
}
|
|
|
|
// Handle empty or missing responses
|
|
if (response == null || response.length == 0) {
|
|
response = '{"error": "Empty response"}';
|
|
}
|
|
|
|
// Parse response
|
|
let responseObj;
|
|
try {
|
|
responseObj = JSON.parse(response);
|
|
} catch (e) {
|
|
// Not all responses will be JSON
|
|
responseObj = response;
|
|
}
|
|
|
|
// GET_QDN_RESOURCE_URL has custom handling
|
|
const data = event.data;
|
|
if (data.action == "GET_QDN_RESOURCE_URL") {
|
|
if (
|
|
responseObj == null ||
|
|
responseObj.status == null ||
|
|
responseObj.status == "NOT_PUBLISHED"
|
|
) {
|
|
responseObj = {};
|
|
responseObj.error = "Resource does not exist";
|
|
} else {
|
|
responseObj = buildResourceUrl(
|
|
data.service,
|
|
data.name,
|
|
data.identifier,
|
|
data.path,
|
|
false
|
|
);
|
|
}
|
|
}
|
|
|
|
// Respond to app
|
|
if (responseObj.error != null) {
|
|
event.ports[0].postMessage({
|
|
result: null,
|
|
error: responseObj,
|
|
});
|
|
} else {
|
|
event.ports[0].postMessage({
|
|
result: responseObj,
|
|
error: null,
|
|
});
|
|
}
|
|
}
|
|
|
|
function buildResourceUrl(service, name, identifier, path, isLink) {
|
|
if (isLink == false) {
|
|
// If this URL isn't being used as a link, then we need to fetch the data
|
|
// synchronously, instead of showing the loading screen.
|
|
url = "/arbitrary/" + service + "/" + name;
|
|
if (identifier != null) url = url.concat("/" + identifier);
|
|
if (path != null) url = url.concat("?filepath=" + path);
|
|
} else if (_qdnContext == "render") {
|
|
url = "/render/" + service + "/" + name;
|
|
if (path != null)
|
|
url = url.concat((path.startsWith("/") ? "" : "/") + path);
|
|
if (identifier != null) url = url.concat("?identifier=" + identifier);
|
|
} else if (_qdnContext == "gateway") {
|
|
url = "/" + service + "/" + name;
|
|
if (identifier != null) url = url.concat("/" + identifier);
|
|
if (path != null)
|
|
url = url.concat((path.startsWith("/") ? "" : "/") + path);
|
|
} else {
|
|
// domainMap only serves websites right now
|
|
url = "/" + name;
|
|
if (path != null)
|
|
url = url.concat((path.startsWith("/") ? "" : "/") + path);
|
|
}
|
|
|
|
if (isLink) {
|
|
const hasQuery = url.includes("?");
|
|
const queryPrefix = hasQuery ? "&" : "?";
|
|
url += queryPrefix + "theme=" + _qdnTheme + "&lang=" + _qdnLang;
|
|
}
|
|
return url;
|
|
}
|
|
|
|
function extractComponents(url) {
|
|
if (!url.startsWith("qortal://")) {
|
|
return null;
|
|
}
|
|
|
|
url = url.replace(/^(qortal\:\/\/)/, "");
|
|
if (url.includes("/")) {
|
|
let parts = url.split("/");
|
|
const service = parts[0].toUpperCase();
|
|
parts.shift();
|
|
const name = parts[0];
|
|
parts.shift();
|
|
let identifier;
|
|
|
|
if (parts.length > 0) {
|
|
identifier = parts[0]; // Do not shift yet
|
|
// Check if a resource exists with this service, name and identifier combination
|
|
const url =
|
|
"/arbitrary/resource/status/" + service + "/" + name + "/" + identifier;
|
|
const response = httpGet(url);
|
|
const responseObj = JSON.parse(response);
|
|
if (responseObj.totalChunkCount > 0) {
|
|
// Identifier exists, so don't include it in the path
|
|
parts.shift();
|
|
} else {
|
|
identifier = null;
|
|
}
|
|
}
|
|
|
|
const path = parts.join("/");
|
|
|
|
const components = {};
|
|
components["service"] = service;
|
|
components["name"] = name;
|
|
components["identifier"] = identifier;
|
|
components["path"] = path;
|
|
return components;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function convertToResourceUrl(url, isLink) {
|
|
if (!url.startsWith("qortal://")) {
|
|
return null;
|
|
}
|
|
const c = extractComponents(url);
|
|
if (c == null) {
|
|
return null;
|
|
}
|
|
|
|
return buildResourceUrl(c.service, c.name, c.identifier, c.path, isLink);
|
|
}
|
|
|
|
window.addEventListener(
|
|
"message",
|
|
async (event) => {
|
|
if (event == null || event.data == null || event.data.length == 0) {
|
|
return;
|
|
}
|
|
if (event.data.action == null) {
|
|
// This could be a response from the UI
|
|
handleResponse(event, event.data);
|
|
}
|
|
if (
|
|
event.data.requestedHandler != null &&
|
|
event.data.requestedHandler === "UI"
|
|
) {
|
|
// This request was destined for the UI, so ignore it
|
|
return;
|
|
}
|
|
|
|
let url;
|
|
let data = event.data;
|
|
let identifier;
|
|
switch (data.action) {
|
|
case "GET_ACCOUNT_DATA":
|
|
return httpGetAsyncWithEvent(event, "/addresses/" + data.address);
|
|
|
|
case "GET_ACCOUNT_NAMES":
|
|
return httpGetAsyncWithEvent(event, "/names/address/" + data.address);
|
|
|
|
case "SEARCH_NAMES":
|
|
url = "/names/search?";
|
|
if (data.query != null) url = url.concat("&query=" + data.query);
|
|
if (data.prefix != null)
|
|
url = url.concat("&prefix=" + new Boolean(data.prefix).toString());
|
|
if (data.limit != null) url = url.concat("&limit=" + data.limit);
|
|
if (data.offset != null) url = url.concat("&offset=" + data.offset);
|
|
if (data.reverse != null)
|
|
url = url.concat("&reverse=" + new Boolean(data.reverse).toString());
|
|
return httpGetAsyncWithEvent(event, url);
|
|
|
|
case "GET_NAME_DATA":
|
|
return httpGetAsyncWithEvent(event, "/names/" + data.name);
|
|
|
|
case "GET_QDN_RESOURCE_URL":
|
|
// Check status first; URL is built and returned automatically after status check
|
|
url = "/arbitrary/resource/status/" + data.service + "/" + data.name;
|
|
if (data.identifier != null) url = url.concat("/" + data.identifier);
|
|
return httpGetAsyncWithEvent(event, url);
|
|
|
|
case "LINK_TO_QDN_RESOURCE":
|
|
if (data.service == null) data.service = "WEBSITE"; // Default to WEBSITE
|
|
|
|
const nameOfCurrentApp = getNameAfterService(window.location.href);
|
|
// Check to see if the link is an external app. If it is, request that the client opens a new tab instead of manipulating the window's history stack.
|
|
if (nameOfCurrentApp !== data.name) {
|
|
// Attempt to open a new tab and wait for a response
|
|
const navigationPromise = new Promise((resolve, reject) => {
|
|
function handleMessage(event) {
|
|
if (
|
|
event.data?.action === "SET_TAB_SUCCESS" &&
|
|
event.data.payload?.name === data.name
|
|
) {
|
|
window.removeEventListener("message", handleMessage);
|
|
resolve();
|
|
}
|
|
}
|
|
|
|
window.addEventListener("message", handleMessage);
|
|
|
|
// Send the message to the parent window
|
|
openNewTab({
|
|
name: data.name,
|
|
service: data.service,
|
|
identifier: data.identifier,
|
|
path: data.path,
|
|
});
|
|
|
|
// Set a timeout to reject the promise if no response is received within 200ms
|
|
setTimeout(() => {
|
|
window.removeEventListener("message", handleMessage);
|
|
reject(new Error("No response within 200ms"));
|
|
}, 200);
|
|
});
|
|
|
|
// Handle the promise, and if it times out, fall back to the else block
|
|
navigationPromise
|
|
.then(() => {
|
|
console.log("Tab opened successfully");
|
|
})
|
|
.catch(() => {
|
|
console.warn("No response, proceeding with window.location");
|
|
window.location = buildResourceUrl(
|
|
data.service,
|
|
data.name,
|
|
data.identifier,
|
|
data.path,
|
|
true
|
|
);
|
|
});
|
|
} else {
|
|
window.location = buildResourceUrl(
|
|
data.service,
|
|
data.name,
|
|
data.identifier,
|
|
data.path,
|
|
true
|
|
);
|
|
}
|
|
return;
|
|
|
|
case "LIST_QDN_RESOURCES":
|
|
url = "/arbitrary/resources?";
|
|
if (data.service != null) url = url.concat("&service=" + data.service);
|
|
if (data.name != null) url = url.concat("&name=" + data.name);
|
|
if (data.identifier != null)
|
|
url = url.concat("&identifier=" + data.identifier);
|
|
if (data.default != null)
|
|
url = url.concat("&default=" + new Boolean(data.default).toString());
|
|
if (data.includeStatus != null)
|
|
url = url.concat(
|
|
"&includestatus=" + new Boolean(data.includeStatus).toString()
|
|
);
|
|
if (data.includeMetadata != null)
|
|
url = url.concat(
|
|
"&includemetadata=" + new Boolean(data.includeMetadata).toString()
|
|
);
|
|
if (data.nameListFilter != null)
|
|
url = url.concat("&namefilter=" + data.nameListFilter);
|
|
if (data.followedOnly != null)
|
|
url = url.concat(
|
|
"&followedonly=" + new Boolean(data.followedOnly).toString()
|
|
);
|
|
if (data.excludeBlocked != null)
|
|
url = url.concat(
|
|
"&excludeblocked=" + new Boolean(data.excludeBlocked).toString()
|
|
);
|
|
if (data.limit != null) url = url.concat("&limit=" + data.limit);
|
|
if (data.offset != null) url = url.concat("&offset=" + data.offset);
|
|
if (data.reverse != null)
|
|
url = url.concat("&reverse=" + new Boolean(data.reverse).toString());
|
|
return httpGetAsyncWithEvent(event, url);
|
|
|
|
case "SEARCH_QDN_RESOURCES":
|
|
url = "/arbitrary/resources/search?";
|
|
if (data.service != null) url = url.concat("&service=" + data.service);
|
|
if (data.query != null) url = url.concat("&query=" + data.query);
|
|
if (data.identifier != null)
|
|
url = url.concat("&identifier=" + data.identifier);
|
|
if (data.name != null) url = url.concat("&name=" + data.name);
|
|
if (data.names != null)
|
|
data.names.forEach((x, i) => (url = url.concat("&name=" + x)));
|
|
if (data.keywords != null)
|
|
data.keywords.forEach((x, i) => (url = url.concat("&keywords=" + x)));
|
|
if (data.title != null) url = url.concat("&title=" + data.title);
|
|
if (data.description != null)
|
|
url = url.concat("&description=" + data.description);
|
|
if (data.prefix != null)
|
|
url = url.concat("&prefix=" + new Boolean(data.prefix).toString());
|
|
if (data.exactMatchNames != null)
|
|
url = url.concat(
|
|
"&exactmatchnames=" + new Boolean(data.exactMatchNames).toString()
|
|
);
|
|
if (data.default != null)
|
|
url = url.concat("&default=" + new Boolean(data.default).toString());
|
|
if (data.mode != null) url = url.concat("&mode=" + data.mode);
|
|
if (data.minLevel != null)
|
|
url = url.concat("&minlevel=" + data.minLevel);
|
|
if (data.includeStatus != null)
|
|
url = url.concat(
|
|
"&includestatus=" + new Boolean(data.includeStatus).toString()
|
|
);
|
|
if (data.includeMetadata != null)
|
|
url = url.concat(
|
|
"&includemetadata=" + new Boolean(data.includeMetadata).toString()
|
|
);
|
|
if (data.nameListFilter != null)
|
|
url = url.concat("&namefilter=" + data.nameListFilter);
|
|
if (data.followedOnly != null)
|
|
url = url.concat(
|
|
"&followedonly=" + new Boolean(data.followedOnly).toString()
|
|
);
|
|
if (data.excludeBlocked != null)
|
|
url = url.concat(
|
|
"&excludeblocked=" + new Boolean(data.excludeBlocked).toString()
|
|
);
|
|
if (data.before != null) url = url.concat("&before=" + data.before);
|
|
if (data.after != null) url = url.concat("&after=" + data.after);
|
|
if (data.limit != null) url = url.concat("&limit=" + data.limit);
|
|
if (data.offset != null) url = url.concat("&offset=" + data.offset);
|
|
if (data.reverse != null)
|
|
url = url.concat("&reverse=" + new Boolean(data.reverse).toString());
|
|
return httpGetAsyncWithEvent(event, url);
|
|
|
|
case "FETCH_QDN_RESOURCE":
|
|
url = "/arbitrary/" + data.service + "/" + data.name;
|
|
if (data.identifier != null) url = url.concat("/" + data.identifier);
|
|
url = url.concat("?");
|
|
if (data.filepath != null)
|
|
url = url.concat("&filepath=" + data.filepath);
|
|
if (data.rebuild != null)
|
|
url = url.concat("&rebuild=" + new Boolean(data.rebuild).toString());
|
|
if (data.encoding != null)
|
|
url = url.concat("&encoding=" + data.encoding);
|
|
return httpGetAsyncWithEvent(event, url);
|
|
|
|
case "GET_QDN_RESOURCE_STATUS":
|
|
url = "/arbitrary/resource/status/" + data.service + "/" + data.name;
|
|
if (data.identifier != null) url = url.concat("/" + data.identifier);
|
|
url = url.concat("?");
|
|
if (data.build != null)
|
|
url = url.concat("&build=" + new Boolean(data.build).toString());
|
|
return httpGetAsyncWithEvent(event, url);
|
|
|
|
case "GET_QDN_RESOURCE_PROPERTIES":
|
|
identifier = data.identifier != null ? data.identifier : "default";
|
|
url =
|
|
"/arbitrary/resource/properties/" +
|
|
data.service +
|
|
"/" +
|
|
data.name +
|
|
"/" +
|
|
identifier;
|
|
return httpGetAsyncWithEvent(event, url);
|
|
|
|
case "GET_QDN_RESOURCE_METADATA":
|
|
identifier = data.identifier != null ? data.identifier : "default";
|
|
url =
|
|
"/arbitrary/metadata/" +
|
|
data.service +
|
|
"/" +
|
|
data.name +
|
|
"/" +
|
|
identifier;
|
|
return httpGetAsyncWithEvent(event, url);
|
|
|
|
case "SEARCH_CHAT_MESSAGES":
|
|
url = "/chat/messages?";
|
|
if (data.before != null) url = url.concat("&before=" + data.before);
|
|
if (data.after != null) url = url.concat("&after=" + data.after);
|
|
if (data.txGroupId != null)
|
|
url = url.concat("&txGroupId=" + data.txGroupId);
|
|
if (data.involving != null)
|
|
data.involving.forEach(
|
|
(x, i) => (url = url.concat("&involving=" + x))
|
|
);
|
|
if (data.reference != null)
|
|
url = url.concat("&reference=" + data.reference);
|
|
if (data.chatReference != null)
|
|
url = url.concat("&chatreference=" + data.chatReference);
|
|
if (data.hasChatReference != null)
|
|
url = url.concat(
|
|
"&haschatreference=" + new Boolean(data.hasChatReference).toString()
|
|
);
|
|
if (data.encoding != null)
|
|
url = url.concat("&encoding=" + data.encoding);
|
|
if (data.limit != null) url = url.concat("&limit=" + data.limit);
|
|
if (data.offset != null) url = url.concat("&offset=" + data.offset);
|
|
if (data.reverse != null)
|
|
url = url.concat("&reverse=" + new Boolean(data.reverse).toString());
|
|
return httpGetAsyncWithEvent(event, url);
|
|
|
|
case "LIST_GROUPS":
|
|
url = "/groups?";
|
|
if (data.limit != null) url = url.concat("&limit=" + data.limit);
|
|
if (data.offset != null) url = url.concat("&offset=" + data.offset);
|
|
if (data.reverse != null)
|
|
url = url.concat("&reverse=" + new Boolean(data.reverse).toString());
|
|
return httpGetAsyncWithEvent(event, url);
|
|
|
|
case "GET_BALANCE":
|
|
url = "/addresses/balance/" + data.address;
|
|
if (data.assetId != null) url = url.concat("&assetId=" + data.assetId);
|
|
return httpGetAsyncWithEvent(event, url);
|
|
|
|
case "GET_AT":
|
|
url = "/at/" + data.atAddress;
|
|
return httpGetAsyncWithEvent(event, url);
|
|
|
|
case "GET_AT_DATA":
|
|
url = "/at/" + data.atAddress + "/data";
|
|
return httpGetAsyncWithEvent(event, url);
|
|
|
|
case "LIST_ATS":
|
|
url = "/at/byfunction/" + data.codeHash58 + "?";
|
|
if (data.isExecutable != null)
|
|
url = url.concat("&isExecutable=" + data.isExecutable);
|
|
if (data.limit != null) url = url.concat("&limit=" + data.limit);
|
|
if (data.offset != null) url = url.concat("&offset=" + data.offset);
|
|
if (data.reverse != null)
|
|
url = url.concat("&reverse=" + new Boolean(data.reverse).toString());
|
|
return httpGetAsyncWithEvent(event, url);
|
|
|
|
case "FETCH_BLOCK":
|
|
if (data.signature != null) {
|
|
url = "/blocks/signature/" + data.signature;
|
|
} else if (data.height != null) {
|
|
url = "/blocks/byheight/" + data.height;
|
|
}
|
|
url = url.concat("?");
|
|
if (data.includeOnlineSignatures != null)
|
|
url = url.concat(
|
|
"&includeOnlineSignatures=" + data.includeOnlineSignatures
|
|
);
|
|
return httpGetAsyncWithEvent(event, url);
|
|
|
|
case "FETCH_BLOCK_RANGE":
|
|
url = "/blocks/range/" + data.height + "?";
|
|
if (data.count != null) url = url.concat("&count=" + data.count);
|
|
if (data.reverse != null) url = url.concat("&reverse=" + data.reverse);
|
|
if (data.includeOnlineSignatures != null)
|
|
url = url.concat(
|
|
"&includeOnlineSignatures=" + data.includeOnlineSignatures
|
|
);
|
|
return httpGetAsyncWithEvent(event, url);
|
|
|
|
case "SEARCH_TRANSACTIONS":
|
|
url = "/transactions/search?";
|
|
if (data.startBlock != null)
|
|
url = url.concat("&startBlock=" + data.startBlock);
|
|
if (data.blockLimit != null)
|
|
url = url.concat("&blockLimit=" + data.blockLimit);
|
|
if (data.txGroupId != null)
|
|
url = url.concat("&txGroupId=" + data.txGroupId);
|
|
if (data.txType != null)
|
|
data.txType.forEach((x, i) => (url = url.concat("&txType=" + x)));
|
|
if (data.address != null) url = url.concat("&address=" + data.address);
|
|
if (data.confirmationStatus != null)
|
|
url = url.concat("&confirmationStatus=" + data.confirmationStatus);
|
|
if (data.limit != null) url = url.concat("&limit=" + data.limit);
|
|
if (data.offset != null) url = url.concat("&offset=" + data.offset);
|
|
if (data.reverse != null)
|
|
url = url.concat("&reverse=" + new Boolean(data.reverse).toString());
|
|
return httpGetAsyncWithEvent(event, url);
|
|
|
|
case "GET_PRICE":
|
|
url = "/crosschain/price/" + data.blockchain + "?";
|
|
if (data.maxtrades != null)
|
|
url = url.concat("&maxtrades=" + data.maxtrades);
|
|
if (data.inverse != null) url = url.concat("&inverse=" + data.inverse);
|
|
return httpGetAsyncWithEvent(event, url);
|
|
|
|
case "PERFORMING_NON_MANUAL":
|
|
isManualNavigation = false;
|
|
currentIndex = data.currentIndex;
|
|
return;
|
|
|
|
default:
|
|
// Pass to parent (UI), in case they can fulfil this request
|
|
event.data.requestedHandler = "UI";
|
|
parent.postMessage(event.data, "*", [event.ports[0]]);
|
|
|
|
return;
|
|
}
|
|
},
|
|
false
|
|
);
|
|
|
|
/**
|
|
* Listen for and intercept all link click events
|
|
*/
|
|
function interceptClickEvent(e) {
|
|
var target = e.target || e.srcElement;
|
|
if (target.tagName !== "A") {
|
|
target = target.closest("A");
|
|
}
|
|
if (target == null || target.getAttribute("href") == null) {
|
|
return;
|
|
}
|
|
let href = target.getAttribute("href");
|
|
if (href.startsWith("qortal://")) {
|
|
const c = extractComponents(href);
|
|
if (c != null) {
|
|
qortalRequest({
|
|
action: "LINK_TO_QDN_RESOURCE",
|
|
service: c.service,
|
|
name: c.name,
|
|
identifier: c.identifier,
|
|
path: c.path,
|
|
});
|
|
}
|
|
e.preventDefault();
|
|
} else if (
|
|
href.startsWith("http://") ||
|
|
href.startsWith("https://") ||
|
|
href.startsWith("//")
|
|
) {
|
|
// Block external links
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
if (document.addEventListener) {
|
|
document.addEventListener("click", interceptClickEvent);
|
|
} else if (document.attachEvent) {
|
|
document.attachEvent("onclick", interceptClickEvent);
|
|
}
|
|
|
|
/**
|
|
* Intercept image loads from the DOM
|
|
*/
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
const imgElements = document.querySelectorAll("img");
|
|
imgElements.forEach((img) => {
|
|
let url = img.src;
|
|
const newUrl = convertToResourceUrl(url, false);
|
|
if (newUrl != null) {
|
|
document.querySelector("img").src = newUrl;
|
|
}
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Intercept img src updates
|
|
*/
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
const imgElements = document.querySelectorAll("img");
|
|
imgElements.forEach((img) => {
|
|
let observer = new MutationObserver((changes) => {
|
|
changes.forEach((change) => {
|
|
if (change.attributeName.includes("src")) {
|
|
const newUrl = convertToResourceUrl(img.src, false);
|
|
if (newUrl != null) {
|
|
document.querySelector("img").src = newUrl;
|
|
}
|
|
}
|
|
});
|
|
});
|
|
observer.observe(img, { attributes: true });
|
|
});
|
|
});
|
|
|
|
const awaitTimeout = (timeout, reason) =>
|
|
new Promise((resolve, reject) =>
|
|
setTimeout(
|
|
() => (reason === undefined ? resolve() : reject(reason)),
|
|
timeout
|
|
)
|
|
);
|
|
|
|
function getDefaultTimeout(action) {
|
|
if (action != null) {
|
|
// Some actions need longer default timeouts, especially those that create transactions
|
|
switch (action) {
|
|
case "GET_USER_ACCOUNT":
|
|
case "SAVE_FILE":
|
|
case "SIGN_TRANSACTION":
|
|
case "DECRYPT_DATA":
|
|
// User may take a long time to accept/deny the popup
|
|
return 60 * 60 * 1000;
|
|
|
|
case "SEARCH_QDN_RESOURCES":
|
|
// Searching for data can be slow, especially when metadata and statuses are also being included
|
|
return 30 * 1000;
|
|
|
|
case "FETCH_QDN_RESOURCE":
|
|
// Fetching data can take a while, especially if the status hasn't been checked first
|
|
return 60 * 1000;
|
|
|
|
case "PUBLISH_QDN_RESOURCE":
|
|
case "PUBLISH_MULTIPLE_QDN_RESOURCES":
|
|
// Publishing could take a very long time on slow system, due to the proof-of-work computation
|
|
return 60 * 60 * 1000;
|
|
|
|
case "SEND_CHAT_MESSAGE":
|
|
// Chat messages rely on PoW computations, so allow extra time
|
|
return 60 * 1000;
|
|
|
|
case "CREATE_TRADE_BUY_ORDER":
|
|
case "CREATE_TRADE_SELL_ORDER":
|
|
case "CANCEL_TRADE_SELL_ORDER":
|
|
case "VOTE_ON_POLL":
|
|
case "CREATE_POLL":
|
|
case "JOIN_GROUP":
|
|
case "DEPLOY_AT":
|
|
case "SEND_COIN":
|
|
// Allow extra time for other actions that create transactions, even if there is no PoW
|
|
return 5 * 60 * 1000;
|
|
|
|
case "GET_WALLET_BALANCE":
|
|
// Getting a wallet balance can take a while, if there are many transactions
|
|
return 2 * 60 * 1000;
|
|
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
return 30 * 1000;
|
|
}
|
|
|
|
/**
|
|
* Process queued requests when a slot becomes available
|
|
*/
|
|
function processRequestQueue() {
|
|
while (
|
|
activeRequestCount < MAX_CONCURRENT_REQUESTS &&
|
|
requestQueue.length > 0
|
|
) {
|
|
const queuedRequest = requestQueue.shift();
|
|
queuedRequest.execute();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute a request immediately (bypasses queue)
|
|
* @param {object} request - The request payload
|
|
* @param {number} [effectiveTimeoutMs] - Timeout used for this request (for cleanup); if omitted, cleanup uses getDefaultTimeout(request.action)
|
|
*/
|
|
function executeQortalRequestImmediate(request, effectiveTimeoutMs) {
|
|
return new Promise((res, rej) => {
|
|
const channel = new MessageChannel();
|
|
const requestId = Math.random().toString(36).substring(2, 15) + Date.now();
|
|
|
|
// Track this channel for cleanup (effectiveTimeoutMs used so cleanup respects qortalRequest / qortalRequestWithTimeout timeouts)
|
|
pendingMessageChannels.set(requestId, {
|
|
channel: channel,
|
|
request: request,
|
|
timestamp: Date.now(),
|
|
effectiveTimeoutMs: effectiveTimeoutMs,
|
|
});
|
|
|
|
channel.port1.onmessage = ({ data }) => {
|
|
channel.port1.close();
|
|
pendingMessageChannels.delete(requestId);
|
|
activeRequestCount--;
|
|
processRequestQueue(); // Process next queued request
|
|
|
|
if (data.error) {
|
|
rej(data.error);
|
|
} else {
|
|
res(data.result);
|
|
}
|
|
};
|
|
|
|
// Handle port closure/errors
|
|
channel.port1.onmessageerror = () => {
|
|
channel.port1.close();
|
|
pendingMessageChannels.delete(requestId);
|
|
activeRequestCount--;
|
|
processRequestQueue();
|
|
rej(new Error("MessageChannel error"));
|
|
};
|
|
|
|
window.postMessage(request, "*", [channel.port2]);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Make a Qortal (Q-Apps) request with no timeout
|
|
* @param {object} request - The request payload
|
|
* @param {number} [effectiveTimeoutMs] - Timeout used for this request (for orphan cleanup only); if omitted, cleanup uses getDefaultTimeout(request.action)
|
|
*/
|
|
const qortalRequestWithNoTimeout = (request, effectiveTimeoutMs) => {
|
|
return new Promise((res, rej) => {
|
|
const executeRequest = () => {
|
|
activeRequestCount++;
|
|
executeQortalRequestImmediate(request, effectiveTimeoutMs).then(res).catch(rej);
|
|
};
|
|
|
|
// If under concurrent limit, execute immediately
|
|
if (activeRequestCount < MAX_CONCURRENT_REQUESTS) {
|
|
executeRequest();
|
|
} else {
|
|
// Queue the request
|
|
requestQueue.push({ execute: executeRequest });
|
|
}
|
|
});
|
|
};
|
|
|
|
// Pending qortal request deduplication
|
|
const pendingQortalRequests = new Map();
|
|
|
|
/**
|
|
* Create a unique key for request deduplication
|
|
*/
|
|
function getRequestKey(request) {
|
|
// Create a stable key from the request object
|
|
const keyObj = {
|
|
action: request.action,
|
|
// Include key parameters that make requests unique
|
|
service: request.service,
|
|
name: request.name,
|
|
identifier: request.identifier,
|
|
path: request.path,
|
|
address: request.address,
|
|
// For search/query requests
|
|
query: request.query,
|
|
limit: request.limit,
|
|
offset: request.offset,
|
|
};
|
|
return JSON.stringify(keyObj);
|
|
}
|
|
|
|
/**
|
|
* Make a Qortal (Q-Apps) request with the default timeout (10 seconds)
|
|
*/
|
|
const qortalRequest = (request) => {
|
|
// Check if identical request is already pending
|
|
const requestKey = getRequestKey(request);
|
|
|
|
if (pendingQortalRequests.has(requestKey)) {
|
|
debugLog("Request deduplication hit for:", request.action);
|
|
// Return the existing promise instead of creating a new request
|
|
return pendingQortalRequests.get(requestKey);
|
|
}
|
|
|
|
debugLog(
|
|
"New request:",
|
|
request.action,
|
|
"Queue size:",
|
|
requestQueue.length,
|
|
"Active:",
|
|
activeRequestCount
|
|
);
|
|
|
|
// Create new request promise
|
|
const defaultTimeout = getDefaultTimeout(request.action);
|
|
const requestPromise = Promise.race([
|
|
qortalRequestWithNoTimeout(request, defaultTimeout),
|
|
awaitTimeout(defaultTimeout, "The request timed out"),
|
|
])
|
|
.then((result) => {
|
|
debugLog("Request completed:", request.action);
|
|
return result;
|
|
})
|
|
.catch((error) => {
|
|
debugLog("Request failed:", request.action, error);
|
|
throw error;
|
|
})
|
|
.finally(() => {
|
|
// Remove from pending cache when done (success or failure)
|
|
pendingQortalRequests.delete(requestKey);
|
|
});
|
|
|
|
// Store in pending cache
|
|
pendingQortalRequests.set(requestKey, requestPromise);
|
|
|
|
return requestPromise;
|
|
};
|
|
|
|
/**
|
|
* Make a Qortal (Q-Apps) request with a custom timeout, specified in milliseconds
|
|
*/
|
|
const qortalRequestWithTimeout = (request, timeout) => {
|
|
// Check if identical request is already pending
|
|
const requestKey = getRequestKey(request);
|
|
|
|
if (pendingQortalRequests.has(requestKey)) {
|
|
// Return the existing promise instead of creating a new request
|
|
return pendingQortalRequests.get(requestKey);
|
|
}
|
|
|
|
// Create new request promise
|
|
const requestPromise = Promise.race([
|
|
qortalRequestWithNoTimeout(request, timeout),
|
|
awaitTimeout(timeout, "The request timed out"),
|
|
]).finally(() => {
|
|
// Remove from pending cache when done (success or failure)
|
|
pendingQortalRequests.delete(requestKey);
|
|
});
|
|
|
|
// Store in pending cache
|
|
pendingQortalRequests.set(requestKey, requestPromise);
|
|
|
|
return requestPromise;
|
|
};
|
|
|
|
// Clean up channels this long after the request's timeout would have fired
|
|
const CLEANUP_BUFFER_MS = 5000;
|
|
|
|
/**
|
|
* Cleanup orphaned MessageChannels that have been waiting too long.
|
|
* Uses each request's effective timeout (from qortalRequest or qortalRequestWithTimeout) so long-running requests are not closed early.
|
|
*/
|
|
function cleanupOrphanedChannels() {
|
|
const now = Date.now();
|
|
let cleanedCount = 0;
|
|
|
|
for (const [requestId, data] of pendingMessageChannels.entries()) {
|
|
const maxAge = (data.effectiveTimeoutMs != null ? data.effectiveTimeoutMs : getDefaultTimeout(data.request.action)) + CLEANUP_BUFFER_MS;
|
|
if (now - data.timestamp > maxAge) {
|
|
console.warn(
|
|
"Cleaning up orphaned MessageChannel for request:",
|
|
data.request.action,
|
|
"Age:",
|
|
Math.round((now - data.timestamp) / 1000),
|
|
"seconds"
|
|
);
|
|
try {
|
|
data.channel.port1.close();
|
|
} catch (e) {
|
|
// Port may already be closed
|
|
}
|
|
pendingMessageChannels.delete(requestId);
|
|
cleanedCount++;
|
|
}
|
|
}
|
|
|
|
// Cleanup old cache entries
|
|
let cacheCleanedCount = 0;
|
|
for (const [url, cached] of requestCache.entries()) {
|
|
if (now - cached.timestamp > REQUEST_CACHE_TTL * 2) {
|
|
requestCache.delete(url);
|
|
cacheCleanedCount++;
|
|
}
|
|
}
|
|
|
|
if (cleanedCount > 0 || cacheCleanedCount > 0) {
|
|
debugLog(
|
|
"Cleanup complete. Channels:",
|
|
cleanedCount,
|
|
"Cache entries:",
|
|
cacheCleanedCount
|
|
);
|
|
debugLog(
|
|
"Stats - Pending channels:",
|
|
pendingMessageChannels.size,
|
|
"Active requests:",
|
|
activeRequestCount,
|
|
"Queued:",
|
|
requestQueue.length
|
|
);
|
|
}
|
|
}
|
|
|
|
// Run cleanup every 30 seconds
|
|
setInterval(cleanupOrphanedChannels, 30000);
|
|
|
|
/**
|
|
* Send current page details to UI
|
|
*/
|
|
document.addEventListener("DOMContentLoaded", (event) => {
|
|
resetVariables();
|
|
qortalRequest({
|
|
action: "QDN_RESOURCE_DISPLAYED",
|
|
service: _qdnService,
|
|
name: _qdnName,
|
|
identifier: _qdnIdentifier,
|
|
path: _qdnPath,
|
|
});
|
|
// send to the client the first path when the app loads.
|
|
const firstPath = parseUrl(window?.location?.href || "");
|
|
handleQDNResourceDisplayed(firstPath, true);
|
|
// Increment counter when page fully loads
|
|
});
|
|
|
|
/**
|
|
* Handle app navigation
|
|
*/
|
|
navigation.addEventListener("navigate", (event) => {
|
|
const url = new URL(event.destination.url);
|
|
|
|
let fullpath = url.pathname + url.hash;
|
|
const processedPath = fullpath.startsWith(_qdnBase)
|
|
? fullpath.slice(_qdnBase.length)
|
|
: fullpath;
|
|
qortalRequest({
|
|
action: "QDN_RESOURCE_DISPLAYED",
|
|
service: _qdnService,
|
|
name: _qdnName,
|
|
identifier: _qdnIdentifier,
|
|
path: processedPath,
|
|
});
|
|
|
|
// Put a timeout so that the DOMContentLoaded listener's logic executes before the navigate listener
|
|
setTimeout(() => {
|
|
handleQDNResourceDisplayed(processedPath);
|
|
}, 100);
|
|
});
|
|
|
|
/**
|
|
* Cleanup on page unload
|
|
*/
|
|
window.addEventListener("beforeunload", () => {
|
|
// Close all pending MessageChannels
|
|
for (const [requestId, data] of pendingMessageChannels.entries()) {
|
|
try {
|
|
data.channel.port1.close();
|
|
} catch (e) {
|
|
// Port may already be closed
|
|
}
|
|
}
|
|
pendingMessageChannels.clear();
|
|
|
|
// Clear all caches
|
|
requestCache.clear();
|
|
pendingAsyncRequests.clear();
|
|
pendingQortalRequests.clear();
|
|
|
|
// Clear request queue
|
|
requestQueue.length = 0;
|
|
activeRequestCount = 0;
|
|
});
|