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`
` : ''}
+
+
+
+ `
+ }
+
+ 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`
+
+
+ this.displayTabContent('browse')}">
+ this.displayTabContent('followed')}">
+ this.displayTabContent('blocked')}">
+
+
+
+
+
${translate("appspage.schange1")}
+ ${this.renderPublishButton()}
+
+
+
${translate("appspage.schange4")}
+
+
+
+
+ this.doSearch(e)}">
+
+ ${translate("appspage.schange35")}
+
+
+
+ {
+ 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`
+
+
+ ${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)