diff --git a/qortal-ui-plugins/plugins/core/q-app/app-browser/app-browser.src.js b/qortal-ui-plugins/plugins/core/q-app/app-browser/app-browser.src.js new file mode 100644 index 00000000..b64c55bb --- /dev/null +++ b/qortal-ui-plugins/plugins/core/q-app/app-browser/app-browser.src.js @@ -0,0 +1,626 @@ +import { LitElement, html, css } from 'lit' +import { render } from 'lit/html.js' +import { Epml } from '../../../../epml' +import { use, get, translate, translateUnsafeHTML, registerTranslateConfig } from 'lit-translate' + +registerTranslateConfig({ + loader: lang => fetch(`/language/${lang}.json`).then(res => res.json()) +}) + +import '@material/mwc-button' +import '@material/mwc-icon' + +const parentEpml = new Epml({ type: 'WINDOW', source: window.parent }) + +class AppBrowser extends LitElement { + static get properties() { + return { + url: { type: String }, + name: { type: String }, + service: { type: String }, + identifier: { type: String }, + path: { type: String }, + displayUrl: {type: String }, + followedNames: { type: Array }, + blockedNames: { type: Array }, + theme: { type: String, reflect: true } + } + } + + static get observers() { + return ['_kmxKeyUp(amount)'] + } + + static get styles() { + return css` + * { + --mdc-theme-primary: rgb(3, 169, 244); + --mdc-theme-secondary: var(--mdc-theme-primary); + --paper-input-container-focus-color: var(--mdc-theme-primary); + } + + #websitesWrapper paper-button { + float: right; + } + + #websitesWrapper .buttons { + width: auto !important; + } + + .address-bar { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 100px; + background-color: var(--white); + height: 36px; + } + + .address-bar-button mwc-icon { + width: 20px; + } + + .iframe-container { + position: absolute; + top: 36px; + left: 0; + right: 0; + bottom: 0; + border-top: 1px solid var(--black); + } + + .iframe-container iframe { + display: block; + width: 100%; + height: 100%; + border: none; + background-color: var(--white); + } + + input[type=text] { + margin: 0; + padding: 2px 0 0 20px; + border: 0; + height: 34px; + font-size: 16px; + background-color: var(--white); + } + + paper-progress { + --paper-progress-active-color: var(--mdc-theme-primary); + } + + .float-right { + float: right; + } + + ` + } + + constructor() { + super() + this.url = 'about:blank' + + const urlParams = new URLSearchParams(window.location.search); + this.name = urlParams.get('name'); + this.service = urlParams.get('service'); + this.identifier = urlParams.get('identifier') != null ? urlParams.get('identifier') : null; + this.path = urlParams.get('path') != null ? ((urlParams.get('path').startsWith("/") ? "" : "/") + urlParams.get('path')) : ""; + this.followedNames = [] + this.blockedNames = [] + this.theme = localStorage.getItem('qortalTheme') ? localStorage.getItem('qortalTheme') : 'light' + + // Build initial display URL + let displayUrl = "qortal://" + this.service + "/" + this.name; + if (this.identifier != null && data.identifier != "" && this.identifier != "default") displayUrl = displayUrl.concat("/" + this.identifier); + if (this.path != null && this.path != "/") displayUrl = displayUrl.concat(this.path); + this.displayUrl = displayUrl; + + const getFollowedNames = async () => { + + let followedNames = await parentEpml.request('apiCall', { + url: `/lists/followedNames?apiKey=${this.getApiKey()}` + }) + + this.followedNames = followedNames + setTimeout(getFollowedNames, this.config.user.nodeSettings.pingInterval) + } + + const getBlockedNames = async () => { + + let blockedNames = await parentEpml.request('apiCall', { + url: `/lists/blockedNames?apiKey=${this.getApiKey()}` + }) + + this.blockedNames = blockedNames + setTimeout(getBlockedNames, this.config.user.nodeSettings.pingInterval) + } + + const render = () => { + const myNode = window.parent.reduxStore.getState().app.nodeConfig.knownNodes[window.parent.reduxStore.getState().app.nodeConfig.node] + const nodeUrl = myNode.protocol + '://' + myNode.domain + ':' + myNode.port + this.url = `${nodeUrl}/render/${this.service}/${this.name}${this.path != null ? this.path : ""}?theme=${this.theme}&identifier=${this.identifier != null ? this.identifier : ""}`; + } + + const authorizeAndRender = () => { + parentEpml.request('apiCall', { + url: `/render/authorize/${this.name}?apiKey=${this.getApiKey()}`, + method: "POST" + }).then(res => { + if (res.error) { + // Authorization problem - API key incorrect? + } + else { + render() + } + }) + } + + let configLoaded = false + + parentEpml.ready().then(() => { + parentEpml.subscribe('selected_address', async selectedAddress => { + this.selectedAddress = {} + selectedAddress = JSON.parse(selectedAddress) + if (!selectedAddress || Object.entries(selectedAddress).length === 0) return + this.selectedAddress = selectedAddress + }) + parentEpml.subscribe('config', c => { + this.config = JSON.parse(c) + if (!configLoaded) { + authorizeAndRender() + setTimeout(getFollowedNames, 1) + setTimeout(getBlockedNames, 1) + configLoaded = true + } + }) + parentEpml.subscribe('copy_menu_switch', async value => { + + if (value === 'false' && window.getSelection().toString().length !== 0) { + + this.clearSelection() + } + }) + }) + } + + render() { + return html` +
+
+
+ this.goBack()} title="${translate("general.back")}" class="address-bar-button">arrow_back_ios + this.goForward()} title="${translate("browserpage.bchange1")}" class="address-bar-button">arrow_forward_ios + this.refresh()} title="${translate("browserpage.bchange2")}" class="address-bar-button">refresh + this.goBackToList()} title="${translate("browserpage.bchange3")}" class="address-bar-button">home + + this.delete()} title="${translate("browserpage.bchange4")} ${this.service} ${this.name} ${translate("browserpage.bchange5")}" class="address-bar-button float-right">delete + ${this.renderBlockUnblockButton()} + ${this.renderFollowUnfollowButton()} +
+
+ +
+
+
+ ` + } + + firstUpdated() { + + this.changeTheme() + this.changeLanguage() + + window.addEventListener('contextmenu', (event) => { + event.preventDefault() + this._textMenu(event) + }) + + window.addEventListener('click', () => { + parentEpml.request('closeCopyTextMenu', null) + }) + + window.addEventListener('storage', () => { + const checkLanguage = localStorage.getItem('qortalLanguage') + const checkTheme = localStorage.getItem('qortalTheme') + + use(checkLanguage) + + if (checkTheme === 'dark') { + this.theme = 'dark' + } else { + this.theme = 'light' + } + document.querySelector('html').setAttribute('theme', this.theme) + }) + + window.onkeyup = (e) => { + if (e.keyCode === 27) { + parentEpml.request('closeCopyTextMenu', null) + } + } + + window.addEventListener("message", (event) => { + if (event == null || event.data == null || event.data.length == 0 || event.data.action == null) { + return; + } + + let response = "{\"error\": \"Request could not be fulfilled\"}"; + let data = event.data; + console.log("UI received event: " + JSON.stringify(data)); + + switch (data.action) { + case "GET_USER_ACCOUNT": + // For now, we will return this without prompting the user, but we may need to add a prompt later + let account = {}; + account["address"] = this.selectedAddress.address; + account["publicKey"] = this.selectedAddress.base58PublicKey; + response = JSON.stringify(account); + break; + + case "LINK_TO_QDN_RESOURCE": + case "QDN_RESOURCE_DISPLAYED": + // Links are handled by the core, but the UI also listens for these actions in order to update the address bar. + // Note: don't update this.url here, as we don't want to force reload the iframe each time. + let url = "qortal://" + data.service + "/" + data.name; + this.path = data.path != null ? ((data.path.startsWith("/") ? "" : "/") + data.path) : null; + if (data.identifier != null && data.identifier != "" && data.identifier != "default") url = url.concat("/" + data.identifier); + if (this.path != null && this.path != "/") url = url.concat(this.path); + this.name = data.name; + this.service = data.service; + this.identifier = data.identifier; + this.displayUrl = url; + return; + + case "PUBLISH_QDN_RESOURCE": + // Use "default" if user hasn't specified an identifer + if (data.identifier == null) { + data.identifier = "default"; + } + + // Params: data.service, data.name, data.identifier, data.data64, + // TODO: prompt user for publish. If they confirm, call `POST /arbitrary/{service}/{name}/{identifier}/base64` and sign+process transaction + // then set the response string from the core to the `response` variable (defined above) + // If they decline, send back JSON that includes an `error` key, such as `{"error": "User declined request"}` + break; + + case "SEND_CHAT_MESSAGE": + // Params: data.groupId, data.destinationAddress, data.message + // TODO: prompt user to send chat message. If they confirm, sign+process a CHAT transaction + // then set the response string from the core to the `response` variable (defined above) + // If they decline, send back JSON that includes an `error` key, such as `{"error": "User declined request"}` + break; + + case "JOIN_GROUP": + // Params: data.groupId + // TODO: prompt user to join group. If they confirm, sign+process a JOIN_GROUP transaction + // then set the response string from the core to the `response` variable (defined above) + // If they decline, send back JSON that includes an `error` key, such as `{"error": "User declined request"}` + break; + + case "DEPLOY_AT": + // Params: data.creationBytes, data.name, data.description, data.type, data.tags, data.amount, data.assetId, data.fee + // TODO: prompt user to deploy an AT. If they confirm, sign+process a DEPLOY_AT transaction + // then set the response string from the core to the `response` variable (defined above) + // If they decline, send back JSON that includes an `error` key, such as `{"error": "User declined request"}` + break; + + case "GET_WALLET_BALANCE": + // Params: data.coin (QORT / LTC / DOGE / DGB / RVN / ARRR) + // TODO: prompt user to share wallet balance. If they confirm, call `GET /crosschain/:coin/walletbalance`, or for QORT, call `GET /addresses/balance/:address` + // then set the response string from the core to the `response` variable (defined above) + // If they decline, send back JSON that includes an `error` key, such as `{"error": "User declined request"}` + break; + + case "SEND_COIN": + // Params: data.coin, data.destinationAddress, data.amount, data.fee + // TODO: prompt user to send. If they confirm, call `POST /crosschain/:coin/send`, or for QORT, broadcast a PAYMENT transaction + // then set the response string from the core to the `response` variable (defined above) + // If they decline, send back JSON that includes an `error` key, such as `{"error": "User declined request"}` + break; + + default: + console.log("Unhandled message: " + JSON.stringify(data)); + return; + } + + + // Parse response + let responseObj; + try { + responseObj = JSON.parse(response); + } catch (e) { + // Not all responses will be JSON + responseObj = response; + } + + // Respond to app + if (responseObj.error != null) { + event.ports[0].postMessage({ + result: null, + error: responseObj + }); + } + else { + event.ports[0].postMessage({ + result: responseObj, + error: null + }); + } + + }); + } + + changeTheme() { + const checkTheme = localStorage.getItem('qortalTheme') + if (checkTheme === 'dark') { + this.theme = 'dark'; + } else { + this.theme = 'light'; + } + document.querySelector('html').setAttribute('theme', this.theme); + } + + changeLanguage() { + const checkLanguage = localStorage.getItem('qortalLanguage') + + if (checkLanguage === null || checkLanguage.length === 0) { + localStorage.setItem('qortalLanguage', 'us') + use('us') + } else { + use(checkLanguage) + } + } + + renderFollowUnfollowButton() { + // Only show the follow/unfollow button if we have permission to modify the list on this node + if (this.followedNames == null || !Array.isArray(this.followedNames)) { + return html`` + } + + if (this.followedNames.indexOf(this.name) === -1) { + // render follow button + return html` this.follow()} title="${translate("browserpage.bchange7")} ${this.name}" class="address-bar-button float-right">add_to_queue` + } + else { + // render unfollow button + return html` this.unfollow()} title="${translate("browserpage.bchange8")} ${this.name}" class="address-bar-button float-right">remove_from_queue` + } + } + + renderBlockUnblockButton() { + // Only show the block/unblock button if we have permission to modify the list on this node + if (this.blockedNames == null || !Array.isArray(this.blockedNames)) { + return html`` + } + + if (this.blockedNames.indexOf(this.name) === -1) { + // render block button + return html` this.block()} title="${translate("browserpage.bchange9")} ${this.name}" class="address-bar-button float-right">block` + } + else { + // render unblock button + return html` this.unblock()} title="${translate("browserpage.bchange10")} ${this.name}" class="address-bar-button float-right">radio_button_unchecked` + } + } + + + // Navigation + + goBack() { + window.history.back(); + } + + goForward() { + window.history.forward(); + } + + refresh() { + const myNode = window.parent.reduxStore.getState().app.nodeConfig.knownNodes[window.parent.reduxStore.getState().app.nodeConfig.node] + const nodeUrl = myNode.protocol + '://' + myNode.domain + ':' + myNode.port + this.url = `${nodeUrl}/render/${this.service}/${this.name}${this.path != null ? this.path : ""}?theme=${this.theme}&identifier=${this.identifier != null ? this.identifier : ""}`; + } + + goBackToList() { + window.location = "../index.html"; + } + + follow() { + this.followName(this.name); + } + + unfollow() { + this.unfollowName(this.name); + } + + block() { + this.blockName(this.name); + } + + unblock() { + this.unblockName(this.name); + } + + delete() { + this.deleteCurrentResource(); + } + + + async followName(name) { + let items = [ + name + ] + let namesJsonString = JSON.stringify({ "items": items }) + + let ret = await parentEpml.request('apiCall', { + url: `/lists/followedNames?apiKey=${this.getApiKey()}`, + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: `${namesJsonString}` + }) + + if (ret === true) { + // Successfully followed - add to local list + // Remove it first by filtering the list - doing it this way ensures the UI updates + // immediately, as apposed to only adding if it doesn't already exist + this.followedNames = this.followedNames.filter(item => item != name); + this.followedNames.push(name) + } + else { + let err1string = get("browserpage.bchange11") + parentEpml.request('showSnackBar', `${err1string}`) + } + + return ret + } + + async unfollowName(name) { + let items = [ + name + ] + let namesJsonString = JSON.stringify({ "items": items }) + + let ret = await parentEpml.request('apiCall', { + url: `/lists/followedNames?apiKey=${this.getApiKey()}`, + method: 'DELETE', + headers: { + 'Content-Type': 'application/json' + }, + body: `${namesJsonString}` + }) + + if (ret === true) { + // Successfully unfollowed - remove from local list + this.followedNames = this.followedNames.filter(item => item != name); + } + else { + let err2string = get("browserpage.bchange12") + parentEpml.request('showSnackBar', `${err2string}`) + } + + return ret + } + + async blockName(name) { + let items = [ + name + ] + let namesJsonString = JSON.stringify({ "items": items }) + + let ret = await parentEpml.request('apiCall', { + url: `/lists/blockedNames?apiKey=${this.getApiKey()}`, + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: `${namesJsonString}` + }) + + if (ret === true) { + // Successfully blocked - add to local list + // Remove it first by filtering the list - doing it this way ensures the UI updates + // immediately, as apposed to only adding if it doesn't already exist + this.blockedNames = this.blockedNames.filter(item => item != name); + this.blockedNames.push(name) + } + else { + let err3string = get("browserpage.bchange13") + parentEpml.request('showSnackBar', `${err3string}`) + } + + return ret + } + + async unblockName(name) { + let items = [ + name + ] + let namesJsonString = JSON.stringify({ "items": items }) + + let ret = await parentEpml.request('apiCall', { + url: `/lists/blockedNames?apiKey=${this.getApiKey()}`, + method: 'DELETE', + headers: { + 'Content-Type': 'application/json' + }, + body: `${namesJsonString}` + }) + + if (ret === true) { + // Successfully unblocked - remove from local list + this.blockedNames = this.blockedNames.filter(item => item != name); + } + else { + let err4string = get("browserpage.bchange14") + parentEpml.request('showSnackBar', `${err4string}`) + } + + return ret + } + + async deleteCurrentResource() { + if (this.followedNames.indexOf(this.name) != -1) { + // Following name - so deleting won't work + let err5string = get("browserpage.bchange15") + parentEpml.request('showSnackBar', `${err5string}`) + return; + } + + let identifier = this.identifier == null ? "default" : resource.identifier; + + let ret = await parentEpml.request('apiCall', { + url: `/arbitrary/resource/${this.service}/${this.name}/${identifier}?apiKey=${this.getApiKey()}`, + method: 'DELETE' + }) + + if (ret === true) { + this.goBackToList(); + } + else { + let err6string = get("browserpage.bchange16") + parentEpml.request('showSnackBar', `${err6string}`) + } + + return ret + } + + _textMenu(event) { + const getSelectedText = () => { + var text = '' + if (typeof window.getSelection != 'undefined') { + text = window.getSelection().toString() + } else if (typeof this.shadowRoot.selection != 'undefined' && this.shadowRoot.selection.type == 'Text') { + text = this.shadowRoot.selection.createRange().text + } + return text + } + + const checkSelectedTextAndShowMenu = () => { + let selectedText = getSelectedText() + if (selectedText && typeof selectedText === 'string') { + let _eve = { pageX: event.pageX, pageY: event.pageY, clientX: event.clientX, clientY: event.clientY } + let textMenuObject = { selectedText: selectedText, eventObject: _eve, isFrame: true } + parentEpml.request('openCopyTextMenu', textMenuObject) + } + } + checkSelectedTextAndShowMenu() + } + + getApiKey() { + const myNode = window.parent.reduxStore.getState().app.nodeConfig.knownNodes[window.parent.reduxStore.getState().app.nodeConfig.node]; + let apiKey = myNode.apiKey; + return apiKey; + } + + clearSelection() { + window.getSelection().removeAllRanges() + window.parent.getSelection().removeAllRanges() + } +} + +window.customElements.define('app-browser', AppBrowser) diff --git a/qortal-ui-plugins/plugins/core/q-app/app-browser/index.html b/qortal-ui-plugins/plugins/core/q-app/app-browser/index.html new file mode 100644 index 00000000..03cf4220 --- /dev/null +++ b/qortal-ui-plugins/plugins/core/q-app/app-browser/index.html @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + diff --git a/qortal-ui-plugins/plugins/core/q-app/index.html b/qortal-ui-plugins/plugins/core/q-app/index.html new file mode 100644 index 00000000..8898264a --- /dev/null +++ b/qortal-ui-plugins/plugins/core/q-app/index.html @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + diff --git a/qortal-ui-plugins/plugins/core/q-app/publish-app/index.html b/qortal-ui-plugins/plugins/core/q-app/publish-app/index.html new file mode 100644 index 00000000..57f183df --- /dev/null +++ b/qortal-ui-plugins/plugins/core/q-app/publish-app/index.html @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + diff --git a/qortal-ui-plugins/plugins/core/q-app/publish-app/publish-app.src.js b/qortal-ui-plugins/plugins/core/q-app/publish-app/publish-app.src.js new file mode 100644 index 00000000..25ca22cd --- /dev/null +++ b/qortal-ui-plugins/plugins/core/q-app/publish-app/publish-app.src.js @@ -0,0 +1,670 @@ +import { LitElement, html, css } from 'lit' +import { render } from 'lit/html.js' +import { Epml } from '../../../../epml' +import { use, get, translate, translateUnsafeHTML, registerTranslateConfig } from 'lit-translate' + +registerTranslateConfig({ + loader: lang => fetch(`/language/${lang}.json`).then(res => res.json()) +}) + +import '@material/mwc-button' +import '@material/mwc-textfield' +import '@material/mwc-select' +import '@material/mwc-list/mwc-list-item.js' +import '@polymer/paper-progress/paper-progress.js' + +const parentEpml = new Epml({ type: 'WINDOW', source: window.parent }) + +class PublishApp extends LitElement { + static get properties() { + return { + name: { type: String }, + service: { type: String }, + identifier: { type: String }, + category: { type: String }, + uploadType: { type: String }, + showName: { type: Boolean }, + showService: { type: Boolean }, + showIdentifier: { type: Boolean }, + showMetadata: { type: Boolean }, + tags: { type: Array }, + serviceLowercase: { type: String }, + metadata: { type: Array }, + categories: { type: Array }, + names: { type: Array }, + myRegisteredName: { type: String }, + selectedName: { type: String }, + path: { type: String }, + portForwardingEnabled: { type: Boolean }, + amount: { type: Number }, + generalMessage: { type: String }, + successMessage: { type: String }, + errorMessage: { type: String }, + loading: { type: Boolean }, + btnDisable: { type: Boolean }, + theme: { type: String, reflect: true } + } + } + + static get observers() { + return ['_kmxKeyUp(amount)'] + } + + static get styles() { + return css` + * { + --mdc-theme-primary: rgb(3, 169, 244); + --mdc-theme-secondary: var(--mdc-theme-primary); + --paper-input-container-focus-color: var(--mdc-theme-primary); + --lumo-primary-text-color: rgb(0, 167, 245); + --lumo-primary-color-50pct: rgba(0, 167, 245, 0.5); + --lumo-primary-color-10pct: rgba(0, 167, 245, 0.1); + --lumo-primary-color: hsl(199, 100%, 48%); + --lumo-base-color: var(--white); + --lumo-body-text-color: var(--black); + --lumo-secondary-text-color: var(--sectxt); + --lumo-contrast-60pct: var(--vdicon); + --_lumo-grid-border-color: var(--border); + --_lumo-grid-secondary-border-color: var(--border2); + } + + + input[type=text] { + padding: 6px 6px 6px 6px; + color: var(--black); + } + + input[type=file]::file-selector-button { + border: 1px solid transparent; + padding: 6px 6px 6px 6px; + border-radius: 5px; + color: #fff; + background-color: var(--mdc-theme-primary); + transition: 1s; + } + + input[type=file]::file-selector-button:hover { + color: #000; + background-color: #81ecec; + border: 1px solid transparent; + } + + #publishWrapper paper-button { + float: right; + } + + #publishWrapper .buttons { + width: auto !important; + } + + mwc-textfield { + margin: 0; + } + + paper-progress { + --paper-progress-active-color: var(--mdc-theme-primary); + } + + .upload-text { + display: block; + font-size: 14px; + color: var(--black); + } + + .address-bar { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 100px; + background-color: var(--white); + height: 36px; + } + + .address-bar-button mwc-icon { + width: 30px; + } + ` + } + + constructor() { + super() + + this.showName = false; + this.showService = false + this.showIdentifier = false + this.showMetadata = false + + const urlParams = new URLSearchParams(window.location.search) + this.name = urlParams.get('name') + this.service = urlParams.get('service') + this.identifier = urlParams.get('identifier') + this.category = urlParams.get('category') + this.uploadType = urlParams.get('uploadType') !== "null" ? urlParams.get('uploadType') : "file" + + if (urlParams.get('showName') === "true") { + this.showName = true + } + + if (urlParams.get('showService') === "true") { + this.showService = true + } + + if (urlParams.get('showIdentifier') === "true") { + this.showIdentifier = true + } + + if (urlParams.get('showMetadata') === "true") { + this.showMetadata = true + } + + if (this.identifier != null) { + if (this.identifier === "null" || this.identifier.trim().length == 0) { + this.identifier = null + } + } + + // Default to true so the message doesn't appear and disappear quickly + this.portForwardingEnabled = true + this.names = [] + this.myRegisteredName = '' + this.selectedName = 'invalid' + this.path = '' + this.successMessage = '' + this.generalMessage = '' + this.errorMessage = '' + this.loading = false + this.btnDisable = false + this.theme = localStorage.getItem('qortalTheme') ? localStorage.getItem('qortalTheme') : 'light' + + const fetchNames = () => { + parentEpml.request('apiCall', {url: `/names/address/${this.selectedAddress.address}?limit=0&reverse=true`}).then(res => { + setTimeout(() => { + this.names = res + if (res[0] != null) { + this.myRegisteredName = res[0].name; + } + }, 1) + }) + setTimeout(fetchNames, this.config.user.nodeSettings.pingInterval) + } + + const fetchCategories = () => { + parentEpml.request('apiCall', {url: `/arbitrary/categories`}).then(res => { + setTimeout(() => { + this.categories = res + }, 1) + }) + setTimeout(fetchCategories, this.config.user.nodeSettings.pingInterval) + } + + const fetchPeersSummary = () => { + parentEpml.request('apiCall', {url: `/peers/summary`}).then(res => { + setTimeout(() => { + this.portForwardingEnabled = (res.inboundConnections != null && res.inboundConnections > 0); + }, 1) + }) + setTimeout(fetchPeersSummary, this.config.user.nodeSettings.pingInterval) + } + + let configLoaded = false + + parentEpml.ready().then(() => { + parentEpml.subscribe('selected_address', async selectedAddress => { + this.selectedAddress = {} + selectedAddress = JSON.parse(selectedAddress) + if (!selectedAddress || Object.entries(selectedAddress).length === 0) return + this.selectedAddress = selectedAddress + }) + + parentEpml.subscribe('config', c => { + if (!configLoaded) { + setTimeout(fetchNames, 1) + setTimeout(fetchCategories, 1) + setTimeout(fetchPeersSummary, 1) + configLoaded = true + } + this.config = JSON.parse(c) + }) + + parentEpml.subscribe('copy_menu_switch', async value => { + if (value === 'false' && window.getSelection().toString().length !== 0) { + this.clearSelection() + } + }) + }) + } + + render() { + return html` +
+
+
+ this.goBack()} class="address-bar-button">arrow_back_ios ${translate("general.back")} +
+ +
+

${translate("publishpage.pchange1")} / ${translate("publishpage.pchange2")} Q-App

+

${translate("publishpage.pchange3")}

+
+
+ +

+ this.selectName(e)} style="min-width: 130px; max-width:100%; width:100%;"> + + ${this.myRegisteredName} + +

+
+

+ +

+

+ +

+

+ + ${this.categories.map((c, index) => html` + ${c.name} + `)} + +

+

+ + + + + +

+
+ ${this.renderUploadField()} +

+ +

+

+ +

+

${this.generalMessage}

+

${this.errorMessage}

+

${this.successMessage}

+ ${this.loading ? html` ` : ''} +
+
+ this.doPublish(e)}> ${translate("publishpage.pchange11")} +
+
+
+
+ ` + } + + firstUpdated() { + + this.changeTheme() + this.changeLanguage() + + window.addEventListener('contextmenu', (event) => { + event.preventDefault() + this._textMenu(event) + }) + + window.addEventListener('click', () => { + parentEpml.request('closeCopyTextMenu', null) + }) + + window.addEventListener('storage', () => { + const checkLanguage = localStorage.getItem('qortalLanguage') + const checkTheme = localStorage.getItem('qortalTheme') + + use(checkLanguage) + + if (checkTheme === 'dark') { + this.theme = 'dark' + } else { + this.theme = 'light' + } + document.querySelector('html').setAttribute('theme', this.theme) + }) + + window.onkeyup = (e) => { + if (e.keyCode === 27) { + parentEpml.request('closeCopyTextMenu', null) + } + } + } + + changeTheme() { + const checkTheme = localStorage.getItem('qortalTheme') + if (checkTheme === 'dark') { + this.theme = 'dark'; + } else { + this.theme = 'light'; + } + document.querySelector('html').setAttribute('theme', this.theme); + } + + changeLanguage() { + const checkLanguage = localStorage.getItem('qortalLanguage') + + if (checkLanguage === null || checkLanguage.length === 0) { + localStorage.setItem('qortalLanguage', 'us') + use('us') + } else { + use(checkLanguage) + } + } + + // Navigation + goBack() { + window.history.back(); + } + + + renderUploadField() { + if (this.uploadType === "file") { + return html` +

+ +

+ ` + } + else if (this.uploadType === "zip") { + return html` +

+ ${translate("publishpage.pchange12")}:
+ +

+ ` + } + else { + return html` +

+ +

+ ` + } + } + + + doPublish(e) { + let registeredName = this.shadowRoot.getElementById('registeredName').value + let service = this.shadowRoot.getElementById('service').value + let identifier = this.shadowRoot.getElementById('identifier').value + + // If name is hidden, use the value passed in via the name parameter + if (!this.showName) { + registeredName = this.name + } + + let file; + let path; + + if (this.uploadType === "file" || this.uploadType === "zip") { + file = this.shadowRoot.getElementById('file').files[0] + } + else if (this.uploadType === "path") { + path = this.shadowRoot.getElementById('path').value + } + + this.generalMessage = '' + this.successMessage = '' + this.errorMessage = '' + + if (registeredName === '') { + this.showName = true + let err1string = get("publishpage.pchange14") + parentEpml.request('showSnackBar', `${err1string}`) + } + else if (this.uploadType === "file" && file == null) { + let err2string = get("publishpage.pchange15") + parentEpml.request('showSnackBar', `${err2string}`) + } + else if (this.uploadType === "zip" && file == null) { + let err3string = get("publishpage.pchange16") + parentEpml.request('showSnackBar', `${err3string}`) + } + else if (this.uploadType === "path" && path === '') { + let err4string = get("publishpage.pchange17") + parentEpml.request('showSnackBar', `${err4string}`) + } + else if (service === '') { + let err5string = get("publishpage.pchange18") + parentEpml.request('showSnackBar', `${err5string}`) + } + else { + this.publishData(registeredName, path, file, service, identifier) + } + } + + async publishData(registeredName, path, file, service, identifier) { + this.loading = true + this.btnDisable = true + + const validateName = async (receiverName) => { + let nameRes = await parentEpml.request('apiCall', { + type: 'api', + url: `/names/${receiverName}`, + }) + + return nameRes + } + + const showError = async (errorMessage) => { + this.loading = false + this.btnDisable = false + this.generalMessage = '' + this.successMessage = '' + console.error(errorMessage) + } + + const validate = async () => { + let validNameRes = await validateName(registeredName) + if (validNameRes.error) { + this.errorMessage = "Error: " + validNameRes.message + showError(this.errorMessage) + throw new Error(this.errorMessage); + } + + let err6string = get("publishpage.pchange19") + this.generalMessage = `${err6string}` + + let transactionBytes = await uploadData(registeredName, path, file) + if (transactionBytes.error) { + let err7string = get("publishpage.pchange20") + this.errorMessage = `${err7string}` + transactionBytes.message + showError(this.errorMessage) + throw new Error(this.errorMessage); + } + else if (transactionBytes.includes("Error 500 Internal Server Error")) { + let err8string = get("publishpage.pchange21") + this.errorMessage = `${err8string}` + showError(this.errorMessage) + throw new Error(this.errorMessage); + } + + let err9string = get("publishpage.pchange22") + this.generalMessage = `${err9string}` + + let signAndProcessRes = await signAndProcess(transactionBytes) + if (signAndProcessRes.error) { + let err10string = get("publishpage.pchange20") + this.errorMessage = `${err10string}` + signAndProcessRes.message + showError(this.errorMessage) + throw new Error(this.errorMessage); + } + + let err11string = get("publishpage.pchange23") + + this.btnDisable = false + this.loading = false + this.errorMessage = '' + this.generalMessage = '' + this.successMessage = `${err11string}` + } + + const uploadData = async (registeredName, path, file) => { + let postBody = path + let urlSuffix = "" + if (file != null) { + + // If we're sending zipped data, make sure to use the /zip version of the POST /arbitrary/* API + if (this.uploadType === "zip") { + urlSuffix = "/zip" + } + // If we're sending file data, use the /base64 version of the POST /arbitrary/* API + else if (this.uploadType === "file") { + urlSuffix = "/base64" + } + + // Base64 encode the file to work around compatibility issues between javascript and java byte arrays + let fileBuffer = new Uint8Array(await file.arrayBuffer()) + postBody = Buffer.from(fileBuffer).toString('base64'); + } + + // Optional metadata + let title = encodeURIComponent(this.shadowRoot.getElementById('title').value); + let description = encodeURIComponent(this.shadowRoot.getElementById('description').value); + let category = encodeURIComponent(this.shadowRoot.getElementById('category').value); + let tag1 = encodeURIComponent(this.shadowRoot.getElementById('tag1').value); + let tag2 = encodeURIComponent(this.shadowRoot.getElementById('tag2').value); + let tag3 = encodeURIComponent(this.shadowRoot.getElementById('tag3').value); + let tag4 = encodeURIComponent(this.shadowRoot.getElementById('tag4').value); + let tag5 = encodeURIComponent(this.shadowRoot.getElementById('tag5').value); + + let metadataQueryString = `title=${title}&description=${description}&category=${category}&tags=${tag1}&tags=${tag2}&tags=${tag3}&tags=${tag4}&tags=${tag5}` + + let uploadDataUrl = `/arbitrary/${this.service}/${registeredName}${urlSuffix}?${metadataQueryString}&apiKey=${this.getApiKey()}` + if (identifier != null && identifier.trim().length > 0) { + uploadDataUrl = `/arbitrary/${service}/${registeredName}/${this.identifier}${urlSuffix}?${metadataQueryString}&apiKey=${this.getApiKey()}` + } + + let uploadDataRes = await parentEpml.request('apiCall', { + type: 'api', + method: 'POST', + url: `${uploadDataUrl}`, + body: `${postBody}`, + }) + return uploadDataRes + } + + const convertBytesForSigning = async (transactionBytesBase58) => { + let convertedBytes = await parentEpml.request('apiCall', { + type: 'api', + method: 'POST', + url: `/transactions/convert`, + body: `${transactionBytesBase58}`, + }) + return convertedBytes + } + + const signAndProcess = async (transactionBytesBase58) => { + let convertedBytesBase58 = await convertBytesForSigning(transactionBytesBase58) + if (convertedBytesBase58.error) { + let err12string = get("publishpage.pchange20") + this.errorMessage = `${err12string}` + convertedBytesBase58.message + showError(this.errorMessage) + throw new Error(this.errorMessage); + } + + const convertedBytes = window.parent.Base58.decode(convertedBytesBase58); + const _convertedBytesArray = Object.keys(convertedBytes).map(function (key) { return convertedBytes[key]; }); + const convertedBytesArray = new Uint8Array(_convertedBytesArray) + const convertedBytesHash = new window.parent.Sha256().process(convertedBytesArray).finish().result + + const hashPtr = window.parent.sbrk(32, window.parent.heap); + const hashAry = new Uint8Array(window.parent.memory.buffer, hashPtr, 32); + hashAry.set(convertedBytesHash); + + const difficulty = 14; + const workBufferLength = 8 * 1024 * 1024; + const workBufferPtr = window.parent.sbrk(workBufferLength, window.parent.heap); + + this.errorMessage = ''; + this.successMessage = ''; + let nonce = window.parent.computePow(hashPtr, workBufferPtr, workBufferLength, difficulty) + + let response = await parentEpml.request('sign_arbitrary', { + nonce: this.selectedAddress.nonce, + arbitraryBytesBase58: transactionBytesBase58, + arbitraryBytesForSigningBase58: convertedBytesBase58, + arbitraryNonce: nonce + }) + + let myResponse = { error: '' } + if (response === false) { + let err13string = get("publishpage.pchange24") + myResponse.error = `${err13string}` + } + else { + myResponse = response + } + return myResponse + } + validate() + } + + _textMenu(event) { + const getSelectedText = () => { + var text = '' + if (typeof window.getSelection != 'undefined') { + text = window.getSelection().toString() + } else if (typeof this.shadowRoot.selection != 'undefined' && this.shadowRoot.selection.type == 'Text') { + text = this.shadowRoot.selection.createRange().text + } + return text + } + + const checkSelectedTextAndShowMenu = () => { + let selectedText = getSelectedText() + if (selectedText && typeof selectedText === 'string') { + let _eve = { pageX: event.pageX, pageY: event.pageY, clientX: event.clientX, clientY: event.clientY } + let textMenuObject = { selectedText: selectedText, eventObject: _eve, isFrame: true } + parentEpml.request('openCopyTextMenu', textMenuObject) + } + } + checkSelectedTextAndShowMenu() + } + + + fetchResourceMetadata() { + let identifier = this.identifier != null ? this.identifier : "default"; + + parentEpml.request('apiCall', { + url: `/arbitrary/metadata/${this.service}/${this.name}/${identifier}?apiKey=${this.getApiKey()}` + }).then(res => { + + setTimeout(() => { + this.metadata = res + if (this.metadata != null && this.metadata.category != null) { + this.shadowRoot.getElementById('category').value = this.metadata.category; + } + else { + this.shadowRoot.getElementById('category').value = ""; + } + }, 1) + }) + } + + selectName(e) { + let name = this.shadowRoot.getElementById('registeredName') + this.selectedName = (name.value) + // Update the current name if one has been selected + if (name.value.length > 0) { + this.name = (name.value) + } + this.fetchResourceMetadata(); + } + + getApiKey() { + const myNode = window.parent.reduxStore.getState().app.nodeConfig.knownNodes[window.parent.reduxStore.getState().app.nodeConfig.node]; + let apiKey = myNode.apiKey; + return apiKey; + } + + clearSelection() { + window.getSelection().removeAllRanges() + window.parent.getSelection().removeAllRanges() + } +} + +window.customElements.define('publish-app', PublishApp) diff --git a/qortal-ui-plugins/plugins/core/q-app/q-apps.src.js b/qortal-ui-plugins/plugins/core/q-app/q-apps.src.js new file mode 100644 index 00000000..6f29fd66 --- /dev/null +++ b/qortal-ui-plugins/plugins/core/q-app/q-apps.src.js @@ -0,0 +1,1041 @@ +import { LitElement, html, css } from 'lit' +import { render } from 'lit/html.js' +import { Epml } from '../../../epml.js' +import { use, get, translate, translateUnsafeHTML, registerTranslateConfig } from 'lit-translate' +import { columnBodyRenderer, gridRowDetailsRenderer } from '@vaadin/grid/lit.js' + +registerTranslateConfig({ + loader: lang => fetch(`/language/${lang}.json`).then(res => res.json()) +}) + +import '@material/mwc-icon' +import '@material/mwc-button' +import '@material/mwc-tab-bar' +import '@material/mwc-textfield' + +import '@vaadin/button' +import '@vaadin/grid' +import '@vaadin/icon' +import '@vaadin/icons' +import '@vaadin/text-field' + +const parentEpml = new Epml({ type: 'WINDOW', source: window.parent }) + +class QApps extends LitElement { + static get properties() { + return { + service: { type: String }, + identifier: { type: String }, + loading: { type: Boolean }, + resources: { type: Array }, + pageRes: { type: Array }, + followedNames: { type: Array }, + blockedNames: { type: Array }, + relayMode: { type: Boolean }, + selectedAddress: { type: Object }, + searchName: { type: String }, + searchResources: { type: Array }, + followedResources: { type: Array }, + blockedResources: { type: Array }, + theme: { type: String, reflect: true } + } + } + + static get styles() { + return css` + * { + --mdc-theme-primary: rgb(3, 169, 244); + --paper-input-container-focus-color: var(--mdc-theme-primary); + --lumo-primary-text-color: rgb(0, 167, 245); + --lumo-primary-color-50pct: rgba(0, 167, 245, 0.5); + --lumo-primary-color-10pct: rgba(0, 167, 245, 0.1); + --lumo-primary-color: hsl(199, 100%, 48%); + --lumo-base-color: var(--white); + --lumo-body-text-color: var(--black); + --lumo-secondary-text-color: var(--sectxt); + --lumo-contrast-60pct: var(--vdicon); + --_lumo-grid-border-color: var(--border); + --_lumo-grid-secondary-border-color: var(--border2); + } + + #tabs-1 { + --mdc-tab-height: 50px; + } + + #tabs-1-content { + height: 100%; + padding-bottom: 10px; + } + + mwc-tab-bar { + --mdc-text-transform: none; + --mdc-tab-color-default: var(--black); + --mdc-tab-text-label-color-default: var(--black); + } + + #pages { + display: flex; + flex-wrap: wrap; + padding: 10px 5px 5px 5px; + margin: 0px 20px 20px 20px; + } + + #pages > button { + user-select: none; + padding: 5px; + margin: 0 5px; + border-radius: 10%; + border: 0; + background: transparent; + font: inherit; + outline: none; + cursor: pointer; + color: var(--black); + } + + #pages > button:not([disabled]):hover, + #pages > button:focus { + color: #ccc; + background-color: #eee; + } + + #pages > button[selected] { + font-weight: bold; + color: var(--white); + background-color: #ccc; + } + + #pages > button[disabled] { + opacity: 0.5; + cursor: default; + } + + #apps-list-page { + background: var(--white); + padding: 12px 24px; + } + + #search { + display: flex; + width: 50%; + align-items: center; + } + + .divCard { + border: 1px solid var(--border); + padding: 1em; + box-shadow: 0 .3px 1px 0 rgba(0,0,0,0.14), 0 1px 1px -1px rgba(0,0,0,0.12), 0 1px 2px 0 rgba(0,0,0,0.20); + margin-bottom: 2em; + } + + h2 { + margin:0; + } + + h2, h3, h4, h5 { + color: var(--black); + font-weight: 400; + } + + a.visitSite { + color: var(--black); + text-decoration: none; + } + + [hidden] { + display: hidden !important; + visibility: none !important; + } + + .details { + display: flex; + font-size: 18px; + } + + span { + font-size: 14px; + word-break: break-all; + } + + select { + padding: 13px 20px; + width: 100%; + font-size: 14px; + color: #555; + font-weight: 400; + } + + .title { + font-weight:600; + font-size:12px; + line-height: 32px; + opacity: 0.66; + } + + .resourceTitle { + font-size:15px; + line-height: 32px; + } + + .resourceDescription { + font-size:11px; + padding-bottom: 5px; + } + + .resourceCategoryTags { + font-size:11px; + padding-bottom: 10px; + } + + .resourceRegisteredName { + font-size:15px; + line-height: 32px; + } + + .resourceStatus, .resourceStatus span { + font-size:11px; + } + + .itemList { + padding:0; + } + + .relay-mode-notice { + margin:auto; + text-align:center; + word-break:normal; + font-size:14px; + line-height:20px; + color: var(--relaynodetxt); + } + + img { + border-radius: 25%; + max-width: 65px; + height: 100%; + max-height: 65px; + } + + .green { + --mdc-theme-primary: #198754; + } + ` + } + + constructor() { + super() + this.service = "APP" + this.identifier = null + this.selectedAddress = {} + this.resources = [] + this.pageRes = [] + this.followedNames = [] + this.blockedNames = [] + this.relayMode = null + this.isLoading = false + this.searchName = '' + this.searchResources = [] + this.followedResources = [] + this.blockedResources = [] + this.theme = localStorage.getItem('qortalTheme') ? localStorage.getItem('qortalTheme') : 'light' + } + + render() { + return html` +
+ + + + + +
+
+
+

${translate("appspage.schange1")}

+

${this.renderPublishButton()}

+
+
+

${translate("appspage.schange4")}

+
+ + { + render(html`${this.renderAvatar(data.item)}`, root) + }}> + + { + render(html`${this.renderInfo(data.item)}`, root) + }}> + + { + render(html`${this.renderPublishedBy(data.item)}`, root) + }}> + + { + render(html`${this.renderDownload(data.item)}`, root) + }}> + + { + render(html`${this.renderOpenApp(data.item)}`, root) + }}> + + { + render(html`${this.renderFollowUnfollowButton(data.item)}`, root); + }}> + + { + render(html`${this.renderBlockUnblockButton(data.item)}`, root); + }}> + +
+
+
+

${translate("appspage.schange9")}

+ + { + render(html`${this.renderAvatar(data.item)}`, root) + }}> + + { + render(html`${this.renderInfo(data.item)}`, root) + }}> + + { + render(html`${this.renderPublishedBy(data.item)}`, root) + }}> + + { + render(html`${this.renderDownload(data.item)}`, root) + }}> + + { + render(html`${this.renderOpenApp(data.item)}`, root) + }}> + + { + render(html`${this.renderFollowUnfollowButton(data.item)}`, root); + }}> + + { + render(html`${this.renderBlockUnblockButton(data.item)}`, root); + }}> + + +
+ ${this.pageRes == null ? html` + Loading... + ` : ''} + ${this.isEmptyArray(this.pageRes) ? html` + ${translate("appspage.schange10")} + ` : ''} +
+ ${this.renderRelayModeText()} +
+
+
+

${translate("appspage.schange11")}

+

${this.renderPublishButton()}

+
+
+

${translate("appspage.schange12")}

+ + { + render(html`${this.renderAvatar(data.item)}`, root) + }}> + + { + render(html`${this.renderInfo(data.item)}`, root) + }}> + + { + render(html`${this.renderPublishedBy(data.item)}`, root) + }}> + + { + render(html`${this.renderDownload(data.item)}`, root) + }}> + + { + render(html`${this.renderOpenApp(data.item)}`, root) + }}> + + { + render(html`${this.renderFollowUnfollowButton(data.item)}`, root); + }}> + + { + render(html`${this.renderBlockUnblockButton(data.item)}`, root); + }}> + + + ${this.followedResources == null ? html` + Loading... + ` : ''} + ${this.isEmptyArray(this.followedResources) ? html` + ${translate("appspage.schange13")} + ` : ''} +
+ ${this.renderRelayModeText()} +
+
+
+

${translate("appspage.schange14")}

+

${this.renderPublishButton()}

+
+
+

${translate("appspage.schange15")}

+ + { + render(html`${this.renderAvatar(data.item)}`, root) + }}> + + { + render(html`${this.renderInfo(data.item)}`, root) + }}> + + { + render(html`${this.renderPublishedBy(data.item)}`, root) + }}> + + { + render(html`${this.renderDownload(data.item)}`, root) + }}> + + { + render(html`${this.renderOpenApp(data.item)}`, root) + }}> + + { + render(html`${this.renderFollowUnfollowButton(data.item)}`, root); + }}> + + { + render(html`${this.renderBlockUnblockButton(data.item)}`, root); + }}> + + + ${this.blockedResources == null ? html` + Loading... + ` : ''} + ${this.isEmptyArray(this.blockedResources) ? html` + ${translate("appspage.schange16")} + ` : ''} +
+ ${this.renderRelayModeText()} +
+
+
+ ` + } + + firstUpdated() { + + this.changeTheme() + this.changeLanguage() + this.showapps() + + setTimeout(() => { + this.displayTabContent('browse') + }, 0) + + const getFollowedNames = async () => { + let followedNames = await parentEpml.request('apiCall', { + url: `/lists/followedNames?apiKey=${this.getApiKey()}` + }) + + this.followedNames = followedNames + setTimeout(getFollowedNames, 60000) + } + + const getBlockedNames = async () => { + let blockedNames = await parentEpml.request('apiCall', { + url: `/lists/blockedNames?apiKey=${this.getApiKey()}` + }) + this.blockedNames = blockedNames + setTimeout(getBlockedNames, 60000) + } + + const getRelayMode = async () => { + let relayMode = await parentEpml.request('apiCall', { + url: `/arbitrary/relaymode?apiKey=${this.getApiKey()}` + }) + + this.relayMode = relayMode; + setTimeout(getRelayMode, 600000) + } + + window.addEventListener("contextmenu", (event) => { + event.preventDefault(); + this._textMenu(event) + }); + + window.addEventListener("click", () => { + parentEpml.request('closeCopyTextMenu', null) + }); + + window.onkeyup = (e) => { + if (e.keyCode === 27) { + parentEpml.request('closeCopyTextMenu', null) + } + } + + window.addEventListener('storage', () => { + const checkLanguage = localStorage.getItem('qortalLanguage') + const checkTheme = localStorage.getItem('qortalTheme') + + use(checkLanguage) + + if (checkTheme === 'dark') { + this.theme = 'dark' + } else { + this.theme = 'light' + } + document.querySelector('html').setAttribute('theme', this.theme) + }) + + let configLoaded = false + + parentEpml.ready().then(() => { + parentEpml.subscribe('selected_address', async selectedAddress => { + this.selectedAddress = {} + selectedAddress = JSON.parse(selectedAddress) + if (!selectedAddress || Object.entries(selectedAddress).length === 0) return + this.selectedAddress = selectedAddress + }) + parentEpml.subscribe('config', c => { + if (!configLoaded) { + setTimeout(getFollowedNames, 1) + setTimeout(getBlockedNames, 1) + setTimeout(getRelayMode, 1) + setTimeout(this.getFollowedNamesResource, 1) + setTimeout(this.getBlockedNamesResource, 1) + setInterval(this.getArbitraryResources, 600000) + configLoaded = true + } + this.config = JSON.parse(c) + }) + parentEpml.subscribe('copy_menu_switch', async value => { + if (value === 'false' && window.getSelection().toString().length !== 0) { + this.clearSelection() + } + }) + }) + parentEpml.imReady() + } + + changeTheme() { + const checkTheme = localStorage.getItem('qortalTheme') + if (checkTheme === 'dark') { + this.theme = 'dark'; + } else { + this.theme = 'light'; + } + document.querySelector('html').setAttribute('theme', this.theme) + } + + changeLanguage() { + const checkLanguage = localStorage.getItem('qortalLanguage') + + if (checkLanguage === null || checkLanguage.length === 0) { + localStorage.setItem('qortalLanguage', 'us') + use('us') + } else { + use(checkLanguage) + } + } + + renderCatText() { + return html`${translate("appspage.schange26")}` + } + + displayTabContent(tab) { + const tabBrowseContent = this.shadowRoot.getElementById('tab-browse-content') + const tabFollowedContent = this.shadowRoot.getElementById('tab-followed-content') + const tabBlockedContent = this.shadowRoot.getElementById('tab-blocked-content') + tabBrowseContent.style.display = (tab === 'browse') ? 'block' : 'none' + tabFollowedContent.style.display = (tab === 'followed') ? 'block' : 'none' + tabBlockedContent.style.display = (tab === 'blocked') ? 'block' : 'none' + } + + searchListener(e) { + if (e.key === 'Enter') { + this.doSearch(e) + } + } + + async getResourcesGrid() { + this.resourcesGrid = this.shadowRoot.querySelector(`#resourcesGrid`) + this.pagesControl = this.shadowRoot.querySelector('#pages') + this.pages = undefined + } + + getArbitraryResources = async () => { + const resources = await parentEpml.request('apiCall', { + url: `/arbitrary/resources?service=${this.service}&default=true&limit=0&reverse=false&includestatus=false&includemetadata=false` + }) + this.resources = resources + } + + getFollowedNamesResource = async () => { + const followedRes = await parentEpml.request('apiCall', { + url: `/arbitrary/resources?service=${this.service}&default=true&limit=0&reverse=false&includestatus=true&includemetadata=true&namefilter=followedNames` + }) + this.followedResources = followedRes + } + + getFollowedNamesRefresh = async () => { + let followedNames = await parentEpml.request('apiCall', { + url: `/lists/followedNames?apiKey=${this.getApiKey()}` + }) + this.followedNames = followedNames + } + + getBlockedNamesResource = async () => { + const blockedRes = await parentEpml.request('apiCall', { + url: `/arbitrary/resources?service=${this.service}&default=true&limit=0&reverse=false&includestatus=true&includemetadata=true&namefilter=blockedNames` + }) + this.blockedResources = blockedRes + } + + getBlockedNamesRefresh = async () => { + let blockedNames = await parentEpml.request('apiCall', { + url: `/lists/blockedNames?apiKey=${this.getApiKey()}` + }) + this.blockedNames = blockedNames + } + + async getData(offset) { + const myNode = window.parent.reduxStore.getState().app.nodeConfig.knownNodes[window.parent.reduxStore.getState().app.nodeConfig.node] + const nodeUrl = myNode.protocol + '://' + myNode.domain + ':' + myNode.port + let jsonOffsetUrl = `${nodeUrl}/arbitrary/resources?service=APP&default=true&limit=20&offset=${offset}&reverse=false&includestatus=true&includemetadata=true` + + const jsonOffsetRes = await fetch(jsonOffsetUrl) + const jsonOffsetData = await jsonOffsetRes.json() + + this.pageRes = jsonOffsetData + } + + async updateItemsFromPage(page) { + if (page === undefined) { + return + } + + if (!this.pages) { + this.pages = Array.apply(null, { length: Math.ceil(this.resources.length / 20) }).map((item, index) => { + return index + 1 + }) + + let offset = 0 + + const prevBtn = document.createElement('button') + prevBtn.textContent = '<' + prevBtn.addEventListener('click', () => { + if (parseInt(this.pagesControl.querySelector('[selected]').textContent) > 1) { + offset = (parseInt(this.pagesControl.querySelector('[selected]').textContent) - 2) * 20 + } else { + offset = 0 + } + this.getData(offset); + const selectedPage = parseInt(this.pagesControl.querySelector('[selected]').textContent) + this.updateItemsFromPage(selectedPage - 1) + }) + this.pagesControl.appendChild(prevBtn) + + this.pages.forEach((pageNumber) => { + const pageBtn = document.createElement('button') + pageBtn.textContent = pageNumber + let offset = 0; + pageBtn.addEventListener('click', (e) => { + if (parseInt(e.target.textContent) > 1) { + offset = (parseInt(e.target.textContent) - 1) * 20 + } else { + offset = 0 + } + this.getData(offset); + this.updateItemsFromPage(parseInt(e.target.textContent)) + }) + if (pageNumber === page) { + pageBtn.setAttribute('selected', true) + } + this.pagesControl.appendChild(pageBtn) + }) + + const nextBtn = window.document.createElement('button') + nextBtn.textContent = '>' + nextBtn.addEventListener('click', () => { + if (parseInt(this.pagesControl.querySelector('[selected]').textContent) >= 1) { + offset = ((parseInt(this.pagesControl.querySelector('[selected]').textContent) + 1) * 20) - 20 + } else { + offset = 0 + } + this.getData(offset); + const selectedPage = parseInt(this.pagesControl.querySelector('[selected]').textContent) + this.updateItemsFromPage(selectedPage + 1) + }) + this.pagesControl.appendChild(nextBtn) + } + + const buttons = Array.from(this.pagesControl.children) + buttons.forEach((btn, index) => { + if (parseInt(btn.textContent) === page) { + btn.setAttribute('selected', true) + } else { + btn.removeAttribute('selected') + } + if (index === 0) { + if (page === 1) { + btn.setAttribute('disabled', '') + } else { + btn.removeAttribute('disabled') + } + } + if (index === buttons.length - 1) { + if (page === this.pages.length) { + btn.setAttribute('disabled', '') + } else { + btn.removeAttribute('disabled') + } + } + }) + } + + async showapps() { + await this.getData(0) + await this.getArbitraryResources() + await this.getResourcesGrid() + await this.updateItemsFromPage(1, true) + } + + doSearch(e) { + this.searchResult() + } + + async searchResult() { + let searchName = this.shadowRoot.getElementById('searchName').value + if (searchName.length === 0) { + let err1string = get("appspage.schange34") + parentEpml.request('showSnackBar', `${err1string}`) + } else { + let searchResources = await parentEpml.request('apiCall', { + url: `/arbitrary/resources/search?service=${this.service}&query=${searchName}&default=true&limit=5&reverse=false&includestatus=true&includemetadata=true` + }) + if (this.isEmptyArray(searchResources)) { + let err2string = get("appspage.schange17") + parentEpml.request('showSnackBar', `${err2string}`) + } else { + this.searchResources = searchResources + } + } + } + + renderAvatar(appObj) { + let name = appObj.name + const myNode = window.parent.reduxStore.getState().app.nodeConfig.knownNodes[window.parent.reduxStore.getState().app.nodeConfig.node] + const nodeUrl = myNode.protocol + '://' + myNode.domain + ':' + myNode.port + const url = `${nodeUrl}/arbitrary/THUMBNAIL/${name}/qortal_avatar?async=true&apiKey=${this.getApiKey()}` + return html`` + } + + renderRelayModeText() { + if (this.relayMode === true) { + return html`
${translate("appspage.schange18")} "relayModeEnabled": false ${translate("appspage.schange19")} settings.json
` + } + else if (this.relayMode === false) { + return html`
${translate("appspage.schange20")} "relayModeEnabled": true ${translate("appspage.schange19")} settings.json
` + } + return html`` + } + + renderPublishButton() { + if (this.followedNames == null || !Array.isArray(this.followedNames)) { + return html`` + } + return html` this.publishApp()}>add${translate("appspage.schange21")}` + } + + renderDownload(downObj) { + if (downObj.status.description === "Published but not yet downloaded") { + return html` this.downloadApp(downObj)}>` + } else if (downObj.status.description === "Ready") { + return html`` + } else { + return html`` + } + } + + async downloadApp(downObj) { + this.showChunks(downObj) + await parentEpml.request('apiCall', { + url: `/arbitrary/resource/status/APP/${downObj.name}?build=true&apiKey=${this.getApiKey()}` + }) + this.showApps() + } + + showChunks(downObj) { + } + + renderOpenApp(openObj) { + if (openObj.status.description === "Published but not yet downloaded") { + return html`` + } else if (openObj.status.description === "Ready") { + return html`` + } else { + return html`` + } + } + + publishApp() { + window.location.href = `publish-app/index.html?service=${this.service}&identifier=${this.identifier}&uploadType=zip&category=app&showName=true&showService=false&showIdentifier=false&showMetadata=true` + } + + async followName(appObj) { + let name = appObj.name + let items = [ + name + ] + let namesJsonString = JSON.stringify({ "items": items }) + + let ret = await parentEpml.request('apiCall', { + url: `/lists/followedNames?apiKey=${this.getApiKey()}`, + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: `${namesJsonString}` + }) + + if (ret === true) { + this.followedNames = this.followedNames.filter(item => item != name) + this.followedNames.push(name) + this.getFollowedNamesRefresh() + this.getFollowedNamesResource() + this.getArbitraryResources() + this.updateComplete.then(() => this.requestUpdate()) + } else { + let err3string = get("appspage.schange22") + parentEpml.request('showSnackBar', `${err3string}`) + } + return ret + } + + async unfollowName(appObj) { + let name = appObj.name + let items = [ + name + ] + let namesJsonString = JSON.stringify({ "items": items }) + + let ret = await parentEpml.request('apiCall', { + url: `/lists/followedNames?apiKey=${this.getApiKey()}`, + method: 'DELETE', + headers: { + 'Content-Type': 'application/json' + }, + body: `${namesJsonString}` + }) + + if (ret === true) { + this.followedNames = this.followedNames.filter(item => item != name) + this.getFollowedNamesRefresh() + this.getFollowedNamesResource() + this.getArbitraryResources() + this.updateComplete.then(() => this.requestUpdate()) + } else { + let err4string = get("appspage.schange23") + parentEpml.request('showSnackBar', `${err4string}`) + } + return ret + } + + async blockName(appObj) { + let name = appObj.name + let items = [ + name + ] + let namesJsonString = JSON.stringify({ "items": items }) + + let ret = await parentEpml.request('apiCall', { + url: `/lists/blockedNames?apiKey=${this.getApiKey()}`, + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: `${namesJsonString}` + }) + + if (ret === true) { + this.blockedNames = this.blockedNames.filter(item => item != name) + this.blockedNames.push(name) + this.getBlockedNamesRefresh() + this.getBlockedNamesResource() + this.getArbitraryResources() + this.updateComplete.then(() => this.requestUpdate()) + } else { + let err5string = get("appspage.schange24") + parentEpml.request('showSnackBar', `${err5string}`) + } + return ret + } + + async unblockName(appObj) { + let name = appObj.name + let items = [ + name + ] + let namesJsonString = JSON.stringify({ "items": items }) + + let ret = await parentEpml.request('apiCall', { + url: `/lists/blockedNames?apiKey=${this.getApiKey()}`, + method: 'DELETE', + headers: { + 'Content-Type': 'application/json' + }, + body: `${namesJsonString}` + }) + + if (ret === true) { + this.blockedNames = this.blockedNames.filter(item => item != name) + this.getBlockedNamesRefresh() + this.getBlockedNamesResource() + this.getArbitraryResources() + this.updateComplete.then(() => this.requestUpdate()) + } else { + let err6string = get("appspage.schange25") + parentEpml.request('showSnackBar', `${err6string}`) + } + return ret + } + + renderInfo(appObj) { + let name = appObj.name + let title = name + let description = "" + let categoryName = this.renderCatText() + let tags = ""; + let sizeReadable = "" + + if (appObj.metadata != null) { + title = appObj.metadata.title; + description = appObj.metadata.description; + categoryName = appObj.metadata.categoryName; + if (appObj.metadata.tags != null && appObj.metadata.tags.length > 0) { + tags = "Tags: " + appObj.metadata.tags.join(", ") + } + } + + if (appObj.size != null) { + sizeReadable = this.bytesToSize(appObj.size); + } + + return html` +
+ ${title} +
+
+ ${description} +
+
+ ${categoryName}  + ${tags.length > 0 ? " | " : ""} +  ${tags}  + ${sizeReadable.length > 0 ? " | " : ""} +  ${translate("appspage.schange27")}: ${sizeReadable} +
+ ` + } + + renderPublishedBy(appObj) { + return html`
${appObj.name}
+
${translate("appspage.schange28")}: ${appObj.status.title}
` + } + + renderSize(appObj) { + if (appObj.size === null) { + return html`` + } + let sizeReadable = this.bytesToSize(appObj.size) + return html`${sizeReadable}` + } + + renderFollowUnfollowButton(appObj) { + let name = appObj.name + + if (this.followedNames == null || !Array.isArray(this.followedNames)) { + return html`` + } + + if (this.followedNames.indexOf(name) === -1) { + return html` this.followName(appObj)}>add_to_queue ${translate("appspage.schange29")}` + } else { + return html` this.unfollowName(appObj)}>remove_from_queue ${translate("appspage.schange30")}` + } + } + + renderBlockUnblockButton(appObj) { + let name = appObj.name + + if (this.blockedNames == null || !Array.isArray(this.blockedNames)) { + return html`` + } + + if (this.blockedNames.indexOf(name) === -1) { + return html` this.blockName(appObj)}>block ${translate("appspage.schange31")}` + } else { + return html` this.unblockName(appObj)}>radio_button_unchecked ${translate("appspage.schange32")}` + } + } + + bytesToSize(bytes) { + var sizes = ['bytes', 'KB', 'MB', 'GB', 'TB'] + if (bytes == 0) return '0 bytes' + var i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024))) + return Math.round(bytes / Math.pow(1024, i), 2) + ' ' + sizes[i] + } + + _textMenu(event) { + const getSelectedText = () => { + var text = ""; + if (typeof window.getSelection != "undefined") { + text = window.getSelection().toString(); + } else if (typeof this.shadowRoot.selection != "undefined" && this.shadowRoot.selection.type == "Text") { + text = this.shadowRoot.selection.createRange().text; + } + return text + } + + const checkSelectedTextAndShowMenu = () => { + let selectedText = getSelectedText(); + if (selectedText && typeof selectedText === 'string') { + let _eve = { pageX: event.pageX, pageY: event.pageY, clientX: event.clientX, clientY: event.clientY } + let textMenuObject = { selectedText: selectedText, eventObject: _eve, isFrame: true } + parentEpml.request('openCopyTextMenu', textMenuObject) + } + } + checkSelectedTextAndShowMenu() + } + + getApiKey() { + const myNode = window.parent.reduxStore.getState().app.nodeConfig.knownNodes[window.parent.reduxStore.getState().app.nodeConfig.node] + let apiKey = myNode.apiKey + return apiKey + } + + clearSelection() { + window.getSelection().removeAllRanges() + window.parent.getSelection().removeAllRanges() + } + + isEmptyArray(arr) { + if (!arr) { return true } + return arr.length === 0 + } +} + +window.customElements.define('q-apps', QApps)