diff --git a/src/main/resources/q-apps/q-apps-gateway.js b/src/main/resources/q-apps/q-apps-gateway.js index d5028dca..f54c320e 100644 --- a/src/main/resources/q-apps/q-apps-gateway.js +++ b/src/main/resources/q-apps/q-apps-gateway.js @@ -1,5 +1,57 @@ console.log("Gateway mode"); +function sendRequestToExtension( + requestType, + payload, + timeout = 750 +) { + return new Promise((resolve, reject) => { + const requestId = Math.random().toString(36).substring(2, 15); // Generate a unique ID for the request + const detail = { + type: requestType, + payload, + requestId, + timeout: timeout / 1000, + }; + + // Store the timeout ID so it can be cleared later + const timeoutId = setTimeout(() => { + document.removeEventListener("qortalExtensionResponses", handleResponse); + reject(new Error("Request timed out")); + }, timeout); // Adjust timeout as necessary + + function handleResponse(event) { + const { requestId: responseId, data } = event.detail; + if (requestId === responseId) { + // Match the response with the request + document.removeEventListener("qortalExtensionResponses", handleResponse); + clearTimeout(timeoutId); // Clear the timeout upon successful response + resolve(data); + } + } + + document.addEventListener("qortalExtensionResponses", handleResponse); + document.dispatchEvent( + new CustomEvent("qortalExtensionRequests", { detail }) + ); + }); +} + + const isExtensionInstalledFunc = async () => { + try { + const response = await sendRequestToExtension( + "REQUEST_IS_INSTALLED", + {}, + 750 + ); + return response; + } catch (error) { + // not installed + } +}; + + + function qdnGatewayShowModal(message) { const modalElementId = "qdnGatewayModal"; @@ -32,7 +84,7 @@ function qdnGatewayShowModal(message) { document.body.appendChild(modalElement); } -window.addEventListener("message", (event) => { +window.addEventListener("message", async (event) => { if (event == null || event.data == null || event.data.length == 0) { return; } @@ -43,7 +95,7 @@ window.addEventListener("message", (event) => { // Gateway mode only cares about requests that were intended for the UI return; } - + let response; let data = event.data; @@ -59,6 +111,8 @@ window.addEventListener("message", (event) => { case "GET_LIST_ITEMS": case "ADD_LIST_ITEMS": case "DELETE_LIST_ITEM": + const isExtInstalledRes = await isExtensionInstalledFunc() + if(isExtInstalledRes?.version) return; const errorString = "Interactive features were requested, but these are not yet supported when viewing via a gateway. To use interactive features, please access using the Qortal UI desktop app. More info at: https://qortal.org"; response = "{\"error\": \"" + errorString + "\"}" diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index d586e2e2..e8a42537 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -1,3 +1,118 @@ +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("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 +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; +} + function httpGet(url) { var request = new XMLHttpRequest(); request.open("GET", url, false); @@ -156,7 +271,7 @@ function convertToResourceUrl(url, isLink) { return buildResourceUrl(c.service, c.name, c.identifier, c.path, isLink); } -window.addEventListener("message", (event) => { +window.addEventListener("message", async (event) => { if (event == null || event.data == null || event.data.length == 0) { return; } @@ -199,10 +314,51 @@ window.addEventListener("message", (event) => { 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 - window.location = buildResourceUrl(data.service, data.name, data.identifier, data.path, true); - return; + 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?"; @@ -351,10 +507,18 @@ window.addEventListener("message", (event) => { 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; } @@ -523,7 +687,8 @@ const qortalRequestWithTimeout = (request, timeout) => /** * Send current page details to UI */ -document.addEventListener('DOMContentLoaded', () => { +document.addEventListener('DOMContentLoaded', (event) => { +resetVariables() qortalRequest({ action: "QDN_RESOURCE_DISPLAYED", service: _qdnService, @@ -531,6 +696,10 @@ document.addEventListener('DOMContentLoaded', () => { 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 }); /** @@ -538,12 +707,20 @@ document.addEventListener('DOMContentLoaded', () => { */ 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: (fullpath.startsWith(_qdnBase)) ? fullpath.slice(_qdnBase.length) : fullpath + path: processedPath }); + + // Put a timeout so that the DOMContentLoaded listener's logic executes before the navigate listener + setTimeout(()=> { + handleQDNResourceDisplayed(processedPath); + }, 100) }); +