diff --git a/qortal-ui-plugins/plugins/core/components/ButtonIconCopy.js b/qortal-ui-plugins/plugins/core/components/ButtonIconCopy.js new file mode 100644 index 00000000..43e09a15 --- /dev/null +++ b/qortal-ui-plugins/plugins/core/components/ButtonIconCopy.js @@ -0,0 +1,68 @@ +import { LitElement, html, css } from 'lit-element' +import { Epml } from '../../../epml.js' + +import '@material/mwc-icon-button' + +const parentEpml = new Epml({ type: 'WINDOW', source: window.parent }) + +class ButtonIconCopy extends LitElement { + static get properties() { + return { + textToCopy: { type: String }, + title: { type: String }, + onSuccessMessage: { type: String }, + onErrorMessage: { type: String }, + buttonSize: { type: String }, + iconSize: { type: String }, + color: { type: String }, + offsetLeft: { type: String }, + offsetRight: { type: String } + } + } + + constructor() { + super() + this.textToCopy = '' + this.title = 'Copy to clipboard' + this.onSuccessMessage = 'Copied to clipboard' + this.onErrorMessage = 'Unable to copy' + this.buttonSize = '48px' + this.iconSize = '24px' + this.color = 'inherit' + this.offsetLeft = '0' + this.offsetRight = '0' + } + + connectedCallback() { + super.connectedCallback(); + this.style.setProperty('--mdc-icon-button-size', this.buttonSize) + this.style.setProperty('--mdc-icon-size', this.iconSize) + this.style.setProperty('color', this.color) + this.style.setProperty('margin-left', this.offsetLeft) + this.style.setProperty('margin-right', this.offsetRight) + } + + render() { + return html` + this.saveToClipboard(this.textToCopy)} + > + + ` + } + + async saveToClipboard(text) { + try { + await navigator.clipboard.writeText(text) + parentEpml.request('showSnackBar', this.onSuccessMessage) + } catch (err) { + parentEpml.request('showSnackBar', this.onErrorMessage) + console.error('Copy to clipboard error:', err) + } + } +} + +window.customElements.define('button-icon-copy', ButtonIconCopy) diff --git a/qortal-ui-plugins/plugins/core/components/ChatHead.js b/qortal-ui-plugins/plugins/core/components/ChatHead.js new file mode 100644 index 00000000..d50cec1a --- /dev/null +++ b/qortal-ui-plugins/plugins/core/components/ChatHead.js @@ -0,0 +1,156 @@ +import { LitElement, html, css } from 'lit-element' +// import { render } from 'lit-html' +import { Epml } from '../../../epml.js' + +import '@material/mwc-icon' + +const parentEpml = new Epml({ type: 'WINDOW', source: window.parent }) + +class ChatHead extends LitElement { + static get properties() { + return { + selectedAddress: { type: Object }, + config: { type: Object }, + chatInfo: { type: Object }, + iconName: { type: String }, + activeChatHeadUrl: { type: String } + } + } + + static get styles() { + return css` + + li { + padding: 10px 2px 20px 5px; + cursor: pointer; + width: 100%; + } + + li:hover { + background-color: #eee; + } + + .active { + background: #ebebeb; + border-left: 4px solid #3498db; + } + + .img-icon { + float: left; + font-size:40px; + } + + .about { + margin-top: 8px; + } + + .about { + padding-left: 8px; + } + + .status { + color: #92959e; + } + + .clearfix:after { + visibility: hidden; + display: block; + font-size: 0; + content: " "; + clear: both; + height: 0; + } + + ` + } + + constructor() { + super() + this.selectedAddress = {} + this.config = { + user: { + node: { + + } + } + } + this.chatInfo = {} + this.iconName = '' + this.activeChatHeadUrl = '' + } + + render() { + + return html` +
  • this.getUrl(this.chatInfo.url)} class="clearfix ${this.activeChatHeadUrl === this.chatInfo.url ? 'active' : ''}"> + account_circle +
    +
    ${this.chatInfo.groupName ? this.chatInfo.groupName : this.chatInfo.name !== undefined ? this.chatInfo.name : this.chatInfo.address.substr(0, 15)} ${this.chatInfo.groupId !== undefined ? 'lock_open' : 'lock'}
    +
    +
  • + ` + } + + + // renderEncryptedIcon(chatInfo) { + + // if (chatInfo.groupId !== undefined) { + // this.iconName = 'lock_open' + // } else { + + // parentEpml.request('apiCall', { + // type: 'api', + // url: `/addresses/publickey/${chatInfo.address}` + // }).then(res => { + + // if (res.error === 102) { + // // Do something here... + // } else if (res !== false) { + // this.iconName = 'lock' + // } else { + // this.iconName = 'lock' + // } + // }) + + // } + + // } + + getUrl(chatUrl) { + + this.onPageNavigation(`/app/q-chat/${chatUrl}`) + } + + onPageNavigation(pageUrl) { + + parentEpml.request('setPageUrl', pageUrl) + } + + firstUpdated() { + let configLoaded = false + + // this.renderEncryptedIcon(this.chatInfo) + + 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) { + configLoaded = true + } + this.config = JSON.parse(c) + }) + }) + + + parentEpml.imReady() + } + + +} + +window.customElements.define('chat-head', ChatHead) diff --git a/qortal-ui-plugins/plugins/core/components/ChatMessage.js b/qortal-ui-plugins/plugins/core/components/ChatMessage.js new file mode 100644 index 00000000..0e0eb73b --- /dev/null +++ b/qortal-ui-plugins/plugins/core/components/ChatMessage.js @@ -0,0 +1,151 @@ +import { LitElement, html, css } from 'lit-element' +import { render } from 'lit-html' +import { Epml } from '../../../epml.js' + + +const parentEpml = new Epml({ type: 'WINDOW', source: window.parent }) + +class ChatMessage extends LitElement { + static get properties() { + return { + selectedAddress: { type: Object }, + config: { type: Object }, + message: { type: Object, reflect: true } + } + } + + static get styles() { + return css` + + .message-data { + margin-bottom: 15px; + } + + .message-data-time { + color: #a8aab1; + font-size: 13px; + padding-left: 6px; + } + + .message { + color: black; + padding: 12px 10px; + line-height: 19px; + font-size: 16px; + border-radius: 7px; + margin-bottom: 20px; + width: 90%; + position: relative; + } + + .message:after { + bottom: 100%; + left: 93%; + border: solid transparent; + content: " "; + height: 0; + width: 0; + position: absolute; + pointer-events: none; + border-bottom-color: #ddd; + border-width: 10px; + margin-left: -10px; + } + + .my-message { + background: #ddd; + border: 2px #ccc solid; + } + + .other-message { + background: #f1f1f1; + border: 2px solid #dedede; + } + + .other-message:after { + border-bottom-color: #f1f1f1; + left: 7%; + } + + .align-left { + text-align: left; + } + + .align-right { + text-align: right; + } + + .float-right { + float: right; + } + + .clearfix:after { + visibility: hidden; + display: block; + font-size: 0; + content: " "; + clear: both; + height: 0; + } + ` + } + + // attributeChangedCallback(name, oldVal, newVal) { + // console.log('attribute change: ', name, newVal.address); + // super.attributeChangedCallback(name, oldVal, newVal); + // } + + constructor() { + super() + this.selectedAddress = {} + this.config = { + user: { + node: { + + } + } + } + this.message = {} + } + + render() { + + return html` +
  • +
    + ${this.message.sender}   + 10:10 AM, Today + +
    +
    + ${this.message.decodedMessage} +
    +
  • + ` + } + + + firstUpdated() { + 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) { + configLoaded = true + } + this.config = JSON.parse(c) + }) + }) + + parentEpml.imReady() + } + +} + +window.customElements.define('chat-message', ChatMessage) diff --git a/qortal-ui-plugins/plugins/core/components/ChatPage.js b/qortal-ui-plugins/plugins/core/components/ChatPage.js new file mode 100644 index 00000000..fc238751 --- /dev/null +++ b/qortal-ui-plugins/plugins/core/components/ChatPage.js @@ -0,0 +1,988 @@ +import { LitElement, html, css } from 'lit-element' +import { Epml } from '../../../epml.js' + +import { escape, unescape } from 'html-escaper'; +import { inputKeyCodes } from '../../utils/keyCodes.js'; + +import './ChatScroller.js' +import './TimeAgo.js' + +import { EmojiPicker } from 'emoji-picker-js'; +import '@polymer/paper-spinner/paper-spinner-lite.js' + +const parentEpml = new Epml({ type: 'WINDOW', source: window.parent }) + +class ChatPage extends LitElement { + static get properties() { + return { + selectedAddress: { type: Object }, + config: { type: Object }, + messages: { type: Array }, + _messages: { type: Array }, + newMessages: { type: Array }, + chatId: { type: String }, + myAddress: { type: String }, + isReceipient: { type: Boolean }, + isLoading: { type: Boolean }, + _publicKey: { type: Object }, + balance: { type: Number }, + socketTimeout: { type: Number }, + messageSignature: { type: String }, + _initialMessages: { type: Array }, + isUserDown: { type: Boolean }, + isPasteMenuOpen: { type: Boolean }, + showNewMesssageBar: { attribute: false }, + hideNewMesssageBar: { attribute: false } + } + } + + static get styles() { + return css` + html { + scroll-behavior: smooth; + } + .chat-text-area { + display: flex; + justify-content: center; + overflow: hidden; + } + .chat-text-area .typing-area { + display: flex; + flex-direction: row; + position: absolute; + bottom: 0; + width: 98%; + box-sizing: border-box; + padding: 5px; + margin-bottom: 8px; + border: 1px solid rgba(0, 0, 0, 0.3); + border-radius: 10px; + } + .chat-text-area .typing-area textarea { + display: none; + } + .chat-text-area .typing-area .chat-editor { + border-color: transparent; + flex: 1; + max-height: 40px; + height: 40px; + margin: 0; + padding: 0; + border: none; + } + .chat-text-area .typing-area .emoji-button { + width: 45px; + height: 40px; + padding: 5px; + border: none; + outline: none; + background: transparent; + cursor: pointer; + max-height: 40px; + } + ` + } + + updated(changedProps) { + // changedProps.forEach((OldProp, name) => { + // if (name === 'messages') { + // this.scrollDownPage() + // } + + // // if (name === 'newMessages') { + // // this.updateChatHistory(this.newMessages) + // // } + // }); + } + + constructor() { + super() + this.getOldMessage = this.getOldMessage.bind(this) + this._sendMessage = this._sendMessage.bind(this) + this._downObserverhandler = this._downObserverhandler.bind(this) + + this.selectedAddress = {} + this.chatId = '' + this.myAddress = '' + this.messages = [] + this._messages = [] + this.newMessages = [] + this._publicKey = { key: '', hasPubKey: false } + this.messageSignature = '' + this._initialMessages = [] + this.balance = 1 + this.isReceipient = false + this.isLoadingMessages = true + this.isLoading = false + this.isUserDown = false + this.isPasteMenuOpen = false + } + + render() { + + // TODO: Build a nice preloader for loading messages... + return html` + ${this.isLoadingMessages ? html`

    Loading Messages...

    ` : this.renderChatScroller(this._initialMessages)} + +
    +
    + + + + +
    +
    + ` + } + + renderChatScroller(initialMessages) { + + return html` ` + } + + getOldMessage(scrollElement) { + + if (this._messages.length <= 15 && this._messages.length >= 1) { // 15 is the default number of messages... + + let __msg = [...this._messages] + this._messages = [] + + return { oldMessages: __msg, scrollElement: scrollElement } + } else if (this._messages.length > 15) { + + return { oldMessages: this._messages.splice(this._messages.length - 15), scrollElement: scrollElement } + } else { + + return false + } + } + + processMessages(messages, isInitial) { + + if (isInitial) { + + this.messages = messages.map((eachMessage) => { + + if (eachMessage.isText === true) { + this.messageSignature = eachMessage.signature + let _eachMessage = this.decodeMessage(eachMessage) + return _eachMessage + } + }) + + this._messages = [...this.messages] + + const adjustMessages = () => { + + let __msg = [...this._messages] + this._messages = [] + this._initialMessages = __msg + } + + // TODO: Determine number of initial messages by screen height... + this._messages.length <= 15 ? adjustMessages() : this._initialMessages = this._messages.splice(this._messages.length - 15); + + this.isLoadingMessages = false + setTimeout(() => this.downElementObserver(), 500) + } else { + + let _newMessages = messages.map((eachMessage) => { + + if (eachMessage.isText === true) { + let _eachMessage = this.decodeMessage(eachMessage) + + if (this.messageSignature !== eachMessage.signature) { + + this.messageSignature = eachMessage.signature + + // What are we waiting for, send in the message immediately... + this.renderNewMessage(_eachMessage) + } + + return _eachMessage + } + }) + + this.newMessages = this.newMessages.concat(_newMessages) + + } + + + } + + /** + * New Message Template implementation, takes in a message object. + * @param { Object } messageObj + * @property id or index + * @property sender and other info.. + */ + chatMessageTemplate(messageObj) { + + return ` +
  • +
    + ${messageObj.senderName ? messageObj.senderName : messageObj.sender} + +
    +
    ${this.emojiPicker.parse(escape(messageObj.decodedMessage))}
    +
  • + ` + } + + renderNewMessage(newMessage) { + + const viewElement = this.shadowRoot.querySelector('chat-scroller').shadowRoot.getElementById('viewElement'); + const downObserver = this.shadowRoot.querySelector('chat-scroller').shadowRoot.getElementById('downObserver'); + const li = document.createElement('li'); + li.innerHTML = this.chatMessageTemplate(newMessage); + li.id = newMessage.signature; + + if (newMessage.sender === this.selectedAddress.address) { + + viewElement.insertBefore(li, downObserver); + viewElement.scrollTop = viewElement.scrollHeight; + } else if (this.isUserDown) { + + // Append the message and scroll to the bottom if user is down the page + viewElement.insertBefore(li, downObserver); + viewElement.scrollTop = viewElement.scrollHeight; + } else { + + viewElement.insertBefore(li, downObserver); + this.showNewMesssageBar(); + } + } + + /** + * Decode Message Method. Takes in a message object and returns a decoded message object + * @param {Object} encodedMessageObj + * + */ + decodeMessage(encodedMessageObj) { + let decodedMessageObj = {} + + if (this.isReceipient === true) { + // direct chat + + if (encodedMessageObj.isEncrypted === true && this._publicKey.hasPubKey === true) { + + let decodedMessage = window.parent.decryptChatMessage(encodedMessageObj.data, window.parent.reduxStore.getState().app.selectedAddress.keyPair.privateKey, this._publicKey.key, encodedMessageObj.reference) + decodedMessageObj = { ...encodedMessageObj, decodedMessage } + } else if (encodedMessageObj.isEncrypted === false) { + + let bytesArray = window.parent.Base58.decode(encodedMessageObj.data) + let decodedMessage = new TextDecoder('utf-8').decode(bytesArray) + decodedMessageObj = { ...encodedMessageObj, decodedMessage } + } else { + + decodedMessageObj = { ...encodedMessageObj, decodedMessage: "Cannot Decrypt Message!" } + } + + } else { + // group chat + + let bytesArray = window.parent.Base58.decode(encodedMessageObj.data) + let decodedMessage = new TextDecoder('utf-8').decode(bytesArray) + decodedMessageObj = { ...encodedMessageObj, decodedMessage } + } + + return decodedMessageObj + } + + async fetchChatMessages(chatId) { + + const initDirect = (cid) => { + + let initial = 0 + + let directSocketTimeout + + let myNode = window.parent.reduxStore.getState().app.nodeConfig.knownNodes[window.parent.reduxStore.getState().app.nodeConfig.node] + let nodeUrl = myNode.domain + ":" + myNode.port + + let directSocketLink + + if (window.parent.location.protocol === "https:") { + + directSocketLink = `wss://${nodeUrl}/websockets/chat/messages?involving=${window.parent.reduxStore.getState().app.selectedAddress.address}&involving=${cid}`; + } else { + + // Fallback to http + directSocketLink = `ws://${nodeUrl}/websockets/chat/messages?involving=${window.parent.reduxStore.getState().app.selectedAddress.address}&involving=${cid}`; + } + + const directSocket = new WebSocket(directSocketLink); + + // Open Connection + directSocket.onopen = () => { + + setTimeout(pingDirectSocket, 50) + } + + // Message Event + directSocket.onmessage = (e) => { + + if (initial === 0) { + + this.isLoadingMessages = true + this.processMessages(JSON.parse(e.data), true) + initial = initial + 1 + } else { + + this.processMessages(JSON.parse(e.data), false) + } + } + + // Closed Event + directSocket.onclose = () => { + clearTimeout(directSocketTimeout) + } + + // Error Event + directSocket.onerror = (e) => { + clearTimeout(directSocketTimeout) + console.log(`[DIRECT-SOCKET ==> ${cid}]: ${e.type}`); + } + + const pingDirectSocket = () => { + directSocket.send('ping') + + directSocketTimeout = setTimeout(pingDirectSocket, 295000) + } + + }; + + const initGroup = (gId) => { + let groupId = Number(gId) + + let initial = 0 + + let groupSocketTimeout + + let myNode = window.parent.reduxStore.getState().app.nodeConfig.knownNodes[window.parent.reduxStore.getState().app.nodeConfig.node] + let nodeUrl = myNode.domain + ":" + myNode.port + + let groupSocketLink + + if (window.parent.location.protocol === "https:") { + + groupSocketLink = `wss://${nodeUrl}/websockets/chat/messages?txGroupId=${groupId}`; + } else { + + // Fallback to http + groupSocketLink = `ws://${nodeUrl}/websockets/chat/messages?txGroupId=${groupId}`; + } + + const groupSocket = new WebSocket(groupSocketLink); + + // Open Connection + groupSocket.onopen = () => { + + setTimeout(pingGroupSocket, 50) + } + + // Message Event + groupSocket.onmessage = (e) => { + + if (initial === 0) { + + this.isLoadingMessages = true + this.processMessages(JSON.parse(e.data), true) + initial = initial + 1 + } else { + + this.processMessages(JSON.parse(e.data), false) + } + } + + // Closed Event + groupSocket.onclose = () => { + clearTimeout(groupSocketTimeout) + } + + // Error Event + groupSocket.onerror = (e) => { + clearTimeout(groupSocketTimeout) + console.log(`[GROUP-SOCKET ==> ${groupId}]: ${e.type}`); + } + + const pingGroupSocket = () => { + groupSocket.send('ping') + + groupSocketTimeout = setTimeout(pingGroupSocket, 295000) + } + + }; + + + if (chatId !== undefined) { + + if (this.isReceipient) { + initDirect(chatId) + } else { + let groupChatId = Number(chatId) + initGroup(groupChatId) + } + + } else { + // ... Render a nice "Error, Go Back" component. + } + + // Add to the messages... TODO: Save messages to localstorage and fetch from it to make it persistent... + } + + _sendMessage() { + this.isLoading = true; + this.chatEditor.disable(); + const messageText = this.mirrorChatInput.value; + + // Format and Sanitize Message + const sanitizedMessage = messageText.replace(/ /gi, ' ').replace(//gi, '\n'); + const trimmedMessage = sanitizedMessage.trim(); + + if (/^\s*$/.test(trimmedMessage)) { + this.isLoading = false; + this.chatEditor.enable(); + } else if (trimmedMessage.length >= 256) { + this.isLoading = false; + this.chatEditor.enable(); + parentEpml.request('showSnackBar', "Maximum Characters per message is 255"); + } else { + this.sendMessage(trimmedMessage); + } + } + + async sendMessage(messageText) { + this.isLoading = true; + + let _reference = new Uint8Array(64); + window.crypto.getRandomValues(_reference); + let reference = window.parent.Base58.encode(_reference); + + const sendMessageRequest = async () => { + if (this.isReceipient === true) { + let chatResponse = await parentEpml.request('chat', { + type: 18, + nonce: this.selectedAddress.nonce, + params: { + timestamp: Date.now(), + recipient: this._chatId, + recipientPublicKey: this._publicKey.key, + message: messageText, + lastReference: reference, + proofOfWorkNonce: 0, + isEncrypted: this._publicKey.hasPubKey === false ? 0 : 1, + isText: 1 + } + }); + + _computePow(chatResponse) + } else { + let groupResponse = await parentEpml.request('chat', { + type: 181, + nonce: this.selectedAddress.nonce, + params: { + timestamp: Date.now(), + groupID: Number(this._chatId), + hasReceipient: 0, + message: messageText, + lastReference: reference, + proofOfWorkNonce: 0, + isEncrypted: 0, // Set default to not encrypted for groups + isText: 1 + } + }); + + _computePow(groupResponse) + } + }; + + const _computePow = async (chatBytes) => { + const _chatBytesArray = Object.keys(chatBytes).map(function (key) { return chatBytes[key]; }); + const chatBytesArray = new Uint8Array(_chatBytesArray); + const chatBytesHash = new window.parent.Sha256().process(chatBytesArray).finish().result; + const hashPtr = window.parent.sbrk(32, window.parent.heap); + const hashAry = new Uint8Array(window.parent.memory.buffer, hashPtr, 32); + hashAry.set(chatBytesHash); + + const difficulty = this.balance === 0 ? 14 : 8; + const workBufferLength = 8 * 1024 * 1024; + const workBufferPtr = window.parent.sbrk(workBufferLength, window.parent.heap); + let nonce = window.parent.computePow(hashPtr, workBufferPtr, workBufferLength, difficulty); + + let _response = await parentEpml.request('sign_chat', { + nonce: this.selectedAddress.nonce, + chatBytesArray: chatBytesArray, + chatNonce: nonce + }); + getSendChatResponse(_response); + }; + + const getSendChatResponse = (response) => { + if (response === true) { + this.chatEditor.resetValue(); + } else if (response.error) { + parentEpml.request('showSnackBar', response.message); + } else { + parentEpml.request('showSnackBar', "Sending failed, Please retry..."); + } + + this.isLoading = false; + this.chatEditor.enable(); + }; + + // Exec.. + sendMessageRequest(); + } + + /** + * Method to set if the user's location is down in the chat + * @param { Boolean } isDown + */ + setIsUserDown(isDown) { + + this.isUserDown = isDown; + } + + _downObserverhandler(entries) { + + if (entries[0].isIntersecting) { + + this.setIsUserDown(true) + this.hideNewMesssageBar() + } else { + + this.setIsUserDown(false) + } + } + + downElementObserver() { + const downObserver = this.shadowRoot.querySelector('chat-scroller').shadowRoot.getElementById('downObserver'); + + const options = { + root: this.shadowRoot.querySelector('chat-scroller').shadowRoot.getElementById('viewElement'), + rootMargin: '100px', + threshold: 1 + } + const observer = new IntersectionObserver(this._downObserverhandler, options) + observer.observe(downObserver) + } + + + firstUpdated() { + // TODO: Load and fetch messages from localstorage (maybe save messages to localstorage...) + + this.emojiPickerHandler = this.shadowRoot.querySelector('.emoji-button'); + this.mirrorChatInput = this.shadowRoot.getElementById('messageBox'); + this.chatMessageInput = this.shadowRoot.getElementById('_chatEditorDOM'); + + document.addEventListener('keydown', (e) => { + if (!this.chatEditor.content.body.matches(':focus')) { + // WARNING: Deprecated methods from KeyBoard Event + if (e.code === "Space" || e.keyCode === 32 || e.which === 32) { + this.chatEditor.insertText(' '); + } else if (inputKeyCodes.includes(e.keyCode)) { + this.chatEditor.insertText(e.key); + return this.chatEditor.focus(); + } else { + return this.chatEditor.focus(); + } + }; + }); + + // Init EmojiPicker + this.emojiPicker = new EmojiPicker({ + style: "twemoji", + twemojiBaseUrl: '/emoji/', + showPreview: false, + showVariants: false, + showAnimation: false, + position: 'top-start', + boxShadow: 'rgba(4, 4, 5, 0.15) 0px 0px 0px 1px, rgba(0, 0, 0, 0.24) 0px 8px 16px 0px' + }); + + this.emojiPicker.on('emoji', selection => { + const emojiHtmlString = `${selection.emoji}`; + this.chatEditor.insertEmoji(emojiHtmlString); + }); + + // Attach Event Handler + this.emojiPickerHandler.addEventListener('click', () => this.emojiPicker.togglePicker(this.emojiPickerHandler)); + + const getAddressPublicKey = () => { + + parentEpml.request('apiCall', { + type: 'api', + url: `/addresses/publickey/${this._chatId}` + }).then(res => { + + if (res.error === 102) { + + this._publicKey.key = '' + this._publicKey.hasPubKey = false + this.fetchChatMessages(this._chatId) + } else if (res !== false) { + + this._publicKey.key = res + this._publicKey.hasPubKey = true + this.fetchChatMessages(this._chatId) + } else { + + this._publicKey.key = '' + this._publicKey.hasPubKey = false + this.fetchChatMessages(this._chatId) + } + }) + }; + + setTimeout(() => { + this.chatId.includes('direct') === true ? this.isReceipient = true : this.isReceipient = false; + this._chatId = this.chatId.split('/')[1]; + + const placeholder = this.isReceipient === true ? `Message ${this._chatId}` : 'Message...'; + this.chatEditorPlaceholder = placeholder; + + this.isReceipient ? getAddressPublicKey() : this.fetchChatMessages(this._chatId); + + // Init ChatEditor + this.initChatEditor(); + }, 100) + + 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.request('apiCall', { + url: `/addresses/balance/${window.parent.reduxStore.getState().app.selectedAddress.address}` + }).then(res => { + this.balance = res + }) + parentEpml.subscribe('frame_paste_menu_switch', async res => { + + res = JSON.parse(res) + if (res.isOpen === false && this.isPasteMenuOpen === true) { + + this.pasteToTextBox(textarea) + this.isPasteMenuOpen = false + } + }) + }) + + parentEpml.imReady(); + } + + pasteToTextBox(textarea) { + + // Return focus to the window + window.focus() + + navigator.clipboard.readText().then(clipboardText => { + + textarea.value += clipboardText + textarea.focus() + }); + } + + pasteMenu(event) { + + let eventObject = { pageX: event.pageX, pageY: event.pageY, clientX: event.clientX, clientY: event.clientY } + parentEpml.request('openFramePasteMenu', eventObject) + } + + isEmptyArray(arr) { + if (!arr) { return true } + return arr.length === 0 + } + + initChatEditor() { + + const ChatEditor = function (editorConfig) { + + const ChatEditor = function () { + const editor = this; + + editor.init(); + }; + + ChatEditor.prototype.getValue = function () { + const editor = this; + + if (editor.content) { + return editor.content.body.innerHTML; + } + }; + + ChatEditor.prototype.setValue = function (value) { + const editor = this; + + if (value) { + editor.content.body.innerHTML = value; + editor.updateMirror(); + } + + editor.focus(); + }; + + ChatEditor.prototype.resetValue = function () { + const editor = this; + + editor.content.body.innerHTML = ''; + editor.updateMirror(); + + editor.focus(); + }; + + ChatEditor.prototype.styles = function () { + const editor = this; + + editor.styles = document.createElement('style'); + editor.styles.setAttribute('type', 'text/css'); + editor.styles.innerText = ` + html { + cursor: text; + } + body { + font-size: 1rem; + line-height: 1.38rem; + font-weight: 400; + font-family: "Open Sans", helvetica, sans-serif; + padding-right: 3px; + text-align: left; + white-space: break-spaces; + word-break: break-word; + outline: none; + } + body[contentEditable=true]:empty:before { + content: attr(data-placeholder); + display: block; + color: rgb(103, 107, 113); + text-overflow: ellipsis; + overflow: hidden; + user-select: none; + white-space: nowrap; + } + body[contentEditable=false]{ + background: rgba(0,0,0,0.1); + } + img.emoji { + width: 1.7em; + height: 1.5em; + margin-bottom: -2px; + vertical-align: bottom; + } + `; + editor.content.head.appendChild(editor.styles); + }; + + ChatEditor.prototype.enable = function () { + const editor = this; + + editor.content.body.setAttribute('contenteditable', 'true'); + editor.focus(); + }; + + ChatEditor.prototype.disable = function () { + const editor = this; + + editor.content.body.setAttribute('contenteditable', 'false'); + }; + + ChatEditor.prototype.state = function () { + const editor = this; + + return editor.content.body.getAttribute('contenteditable'); + }; + + ChatEditor.prototype.focus = function () { + const editor = this; + + editor.content.body.focus(); + }; + + ChatEditor.prototype.clearSelection = function () { + const editor = this; + + let selection = editor.content.getSelection().toString(); + if (!/^\s*$/.test(selection)) editor.content.getSelection().removeAllRanges(); + }; + + ChatEditor.prototype.insertEmoji = function (emojiImg) { + const editor = this; + + const doInsert = () => { + + if (editor.content.queryCommandSupported("InsertHTML")) { + editor.content.execCommand("insertHTML", false, emojiImg); + editor.updateMirror(); + } + }; + + editor.focus(); + return doInsert(); + }; + + ChatEditor.prototype.insertText = function (text) { + const editor = this; + + const parsedText = editorConfig.emojiPicker.parse(text); + const doPaste = () => { + + if (editor.content.queryCommandSupported("InsertHTML")) { + editor.content.execCommand("insertHTML", false, parsedText); + editor.updateMirror(); + } + }; + + editor.focus(); + return doPaste(); + }; + + ChatEditor.prototype.updateMirror = function () { + const editor = this; + + const chatInputValue = editor.getValue(); + const filteredValue = chatInputValue.replace(//g, ''); + + let unescapedValue = editorConfig.unescape(filteredValue); + editor.mirror.value = unescapedValue; + }; + + ChatEditor.prototype.listenChanges = function () { + const editor = this; + + ['drop', 'contextmenu', 'mouseup', 'click', 'touchend', 'keydown', 'blur', 'paste'].map(function (event) { + editor.content.body.addEventListener(event, function (e) { + + if (e.type === 'click') { + + e.preventDefault(); + e.stopPropagation(); + } + + if (e.type === 'paste') { + e.preventDefault(); + + navigator.clipboard.readText().then(clipboardText => { + + let escapedText = editorConfig.escape(clipboardText); + + editor.insertText(escapedText); + }).catch(err => { + + // Fallback if everything fails... + let textData = (e.originalEvent || e).clipboardData.getData('text/plain'); + editor.insertText(textData); + }) + return false; + } + + if (e.type === 'contextmenu') { + e.preventDefault(); + e.stopPropagation(); + return false; + } + + if (e.type === 'keydown') { + + // Handle Enter + if (e.keyCode === 13 && !e.shiftKey) { + + // Update Mirror + editor.updateMirror(); + + if (editor.state() === 'false') return false; + + editorConfig.sendFunc(); + e.preventDefault(); + return false; + } + + // Handle Commands with CTR or CMD + if (e.ctrlKey || e.metaKey) { + switch (e.keyCode) { + case 66: + case 98: e.preventDefault(); + return false; + case 73: + case 105: e.preventDefault(); + return false; + case 85: + case 117: e.preventDefault(); + return false; + } + + return false; + } + } + + if (e.type === 'blur') { + + editor.clearSelection(); + } + + if (e.type === 'drop') { + e.preventDefault(); + + let droppedText = e.dataTransfer.getData('text/plain') + let escapedText = editorConfig.escape(droppedText) + + editor.insertText(escapedText); + return false; + } + + editor.updateMirror(); + }); + }); + + editor.content.addEventListener('click', function (event) { + + event.preventDefault(); + editor.focus(); + }); + }; + + ChatEditor.prototype.init = function () { + const editor = this; + + editor.frame = editorConfig.editableElement; + editor.mirror = editorConfig.mirrorElement; + + editor.content = (editor.frame.contentDocument || editor.frame.document); + editor.content.body.setAttribute('contenteditable', 'true'); + editor.content.body.setAttribute('data-placeholder', editorConfig.placeholder); + editor.content.body.setAttribute('spellcheck', 'false'); + + editor.styles(); + editor.listenChanges(); + }; + + + function doInit() { + + return new ChatEditor(); + }; + + return doInit(); + }; + + const editorConfig = { + mirrorElement: this.mirrorChatInput, + editableElement: this.chatMessageInput, + sendFunc: this._sendMessage, + emojiPicker: this.emojiPicker, + escape: escape, + unescape: unescape, + placeholder: this.chatEditorPlaceholder + }; + + this.chatEditor = new ChatEditor(editorConfig); + } + +} + +window.customElements.define('chat-page', ChatPage) diff --git a/qortal-ui-plugins/plugins/core/components/ChatScroller.js b/qortal-ui-plugins/plugins/core/components/ChatScroller.js new file mode 100644 index 00000000..a0446f86 --- /dev/null +++ b/qortal-ui-plugins/plugins/core/components/ChatScroller.js @@ -0,0 +1,243 @@ +import { LitElement, html, css } from 'lit-element' + +class ChatScroller extends LitElement { + static get properties() { + return { + getNewMessage: { attribute: false }, + getOldMessage: { attribute: false }, + emojiPicker: { attribute: false }, + escapeHTML: { attribute: false }, + initialMessages: { type: Array }, // First set of messages to load.. 15 messages max ( props ) + messages: { type: Array } + } + } + + static get styles() { + return css` + html { + --scrollbarBG: #a1a1a1; + --thumbBG: #6a6c75; + } + + *::-webkit-scrollbar { + width: 11px; + } + + * { + scrollbar-width: thin; + scrollbar-color: var(--thumbBG) var(--scrollbarBG); + } + + *::-webkit-scrollbar-track { + background: var(--scrollbarBG); + } + + *::-webkit-scrollbar-thumb { + background-color: var(--thumbBG); + border-radius: 6px; + border: 3px solid var(--scrollbarBG); + } + + ul { + list-style: none; + margin: 0; + padding: 20px; + } + .chat-list { + overflow-y: auto; + height: 91vh; + box-sizing: border-box; + } + + .message-data { + margin-bottom: 15px; + } + + .message-data-time { + color: #a8aab1; + font-size: 13px; + padding-left: 6px; + } + + .message { + color: black; + padding: 12px 10px; + line-height: 19px; + white-space: pre-line; + word-wrap: break-word; + -webkit-user-select: text; + -moz-user-select: text; + -ms-user-select: text; + user-select: text; + font-size: 16px; + border-radius: 7px; + margin-bottom: 20px; + width: 90%; + position: relative; + } + + .message:after { + bottom: 100%; + left: 93%; + border: solid transparent; + content: " "; + height: 0; + width: 0; + position: absolute; + white-space: pre-line; + word-wrap: break-word; + pointer-events: none; + border-bottom-color: #ddd; + border-width: 10px; + margin-left: -10px; + } + + .emoji { + width: 1.7em; + height: 1.5em; + margin-bottom: -2px; + vertical-align: bottom; + object-fit: contain; + } + + .my-message { + background: #ddd; + border: 2px #ccc solid; + } + + .other-message { + background: #f1f1f1; + border: 2px solid #dedede; + } + + .other-message:after { + border-bottom-color: #f1f1f1; + left: 7%; + } + + .align-left { + text-align: left; + } + + .align-right { + text-align: right; + } + + .float-right { + float: right; + } + + .clearfix:after { + visibility: hidden; + display: block; + font-size: 0; + content: " "; + clear: both; + height: 0; + } + ` + } + + constructor() { + super() + + this.messages = [] + this._upObserverhandler = this._upObserverhandler.bind(this) + this.isLoading = false + this.myAddress = window.parent.reduxStore.getState().app.selectedAddress.address + } + + + render() { + + return html` + + ` + } + + chatMessageTemplate(messageObj) { + + return ` +
  • +
    + ${messageObj.senderName ? messageObj.senderName : messageObj.sender} + +
    +
    ${this.emojiPicker.parse(this.escapeHTML(messageObj.decodedMessage))}
    +
  • + ` + } + + renderChatMessages(messages) { + + messages.forEach(message => { + const li = document.createElement('li'); + li.innerHTML = this.chatMessageTemplate(message); + li.id = message.signature; + this.downObserverElement.before(li); + }); + } + + renderOldMessages(listOfOldMessages) { + + let { oldMessages, scrollElement } = listOfOldMessages; + + let _oldMessages = oldMessages.reverse(); + _oldMessages.forEach(oldMessage => { + const li = document.createElement('li'); + li.innerHTML = this.chatMessageTemplate(oldMessage); + li.id = oldMessage.signature; + this.upObserverElement.after(li); + scrollElement.scrollIntoView({ behavior: 'auto', block: 'center' }); + }); + } + + _getOldMessage(_scrollElement) { + + let listOfOldMessages = this.getOldMessage(_scrollElement) + + if (listOfOldMessages) { + this.renderOldMessages(listOfOldMessages) + } + } + + _upObserverhandler(entries) { + + if (entries[0].isIntersecting) { + let _scrollElement = entries[0].target.nextElementSibling + + this._getOldMessage(_scrollElement) + } + } + + upElementObserver() { + const options = { + root: this.viewElement, + rootMargin: '0px', + threshold: 1 + }; + + const observer = new IntersectionObserver(this._upObserverhandler, options) + observer.observe(this.upObserverElement) + } + + firstUpdated() { + + this.viewElement = this.shadowRoot.getElementById('viewElement'); + this.upObserverElement = this.shadowRoot.getElementById('upObserver'); + this.downObserverElement = this.shadowRoot.getElementById('downObserver'); + + this.renderChatMessages(this.initialMessages) + + // Intialize Observers + this.upElementObserver() + + this.viewElement.scrollTop = this.viewElement.scrollHeight + 50; + } + +} + +window.customElements.define('chat-scroller', ChatScroller) diff --git a/qortal-ui-plugins/plugins/core/components/ChatWelcomePage.js b/qortal-ui-plugins/plugins/core/components/ChatWelcomePage.js new file mode 100644 index 00000000..9cd92435 --- /dev/null +++ b/qortal-ui-plugins/plugins/core/components/ChatWelcomePage.js @@ -0,0 +1,439 @@ +import { LitElement, html, css } from 'lit-element' +import { render } from 'lit-html' +import { Epml } from '../../../epml.js' + +import '@material/mwc-icon' +import '@material/mwc-button' +import '@material/mwc-dialog' +import '@polymer/paper-spinner/paper-spinner-lite.js' + +const parentEpml = new Epml({ type: 'WINDOW', source: window.parent }) + +class ChatWelcomePage extends LitElement { + static get properties() { + return { + selectedAddress: { type: Object }, + config: { type: Object }, + myAddress: { type: Object, reflect: true }, + messages: { type: Array }, + btnDisable: { type: Boolean }, + isLoading: { type: Boolean }, + balance: { type: Number } + } + } + + static get styles() { + return css` + @keyframes moveInBottom { + 0% { + opacity: 0; + transform: translateY(30px); + } + + 100% { + opacity: 1; + transform: translate(0); + } + } + + paper-spinner-lite{ + height: 24px; + width: 24px; + --paper-spinner-color: var(--mdc-theme-primary); + --paper-spinner-stroke-width: 2px; + } + + .welcome-title { + display: block; + overflow: hidden; + font-size: 40px; + color: black; + font-weight: 400; + text-align: center; + white-space: pre-wrap; + overflow-wrap: break-word; + word-break: break-word; + cursor: inherit; + margin-top: 2rem; + } + + .sub-main { + position: relative; + text-align: center; + } + + .center-box { + position: absolute; + top: 45%; + left: 50%; + transform: translate(-50%, 0%); + text-align: center; + } + + .img-icon { + font-size: 150px; + } + + .start-chat { + display: inline-flex; + flex-direction: column; + justify-content: center; + align-content: center; + border: none; + border-radius: 20px; + padding-left: 25px; + padding-right: 25px; + color: white; + background: #6a6c75; + width: 50%; + font-size: 17px; + cursor: pointer; + height: 50px; + margin-top: 1rem; + text-transform: uppercase; + text-decoration: none; + transition: all .2s; + position: relative; + animation: moveInBottom .3s ease-out .50s; + animation-fill-mode: backwards; + } + + .start-chat:hover { + transform: translateY(-3px); + box-shadow: 0 10px 20px rgba(0, 0, 0, .2); + } + + .start-chat::after { + content: ""; + display: inline-flex; + height: 100%; + width: 100%; + border-radius: 100px; + position: absolute; + top: 0; + left: 0; + z-index: -1; + transition: all .4s; + } + + .red { + --mdc-theme-primary: red; + } + + h2 { + margin:0; + } + + h2, h3, h4, h5 { + color:#333; + font-weight: 400; + } + + [hidden] { + display: hidden !important; + visibility: none !important; + } + + .details { + display: flex; + font-size: 18px; + } + + .title { + font-weight:600; + font-size:12px; + line-height: 32px; + opacity: 0.66; + } + + .input { + width: 90%; + border: none; + display: inline-block; + font-size: 16px; + padding: 10px 20px; + border-radius: 5px; + resize: none; + background: #eee; + } + + .textarea { + width: 90%; + border: none; + display: inline-block; + font-size: 16px; + padding: 10px 20px; + border-radius: 5px; + height: 120px; + resize: none; + background: #eee; + } + ` + } + + constructor() { + super() + this.selectedAddress = window.parent.reduxStore.getState().app.selectedAddress.address + this.myAddress = {} + this.balance = 1 + this.messages = [] + this.btnDisable = false + this.isLoading = false + } + + render() { + return html` +
    +
    + Welcome to Q-Chat +
    +
    +
    +
    + chat
    + ${this.myAddress.address} +
    this.shadowRoot.querySelector('#startSecondChatDialog').show()}>New Private Message
    +
    +
    + + + +
    +

    New Private Message

    +
    +
    + +

    Type the name or address of who you want to chat with to send a private message!

    + + +

    + +

    + + Send + + Close + +
    +
    + ` + } + + firstUpdated() { + const stopKeyEventPropagation = (e) => { + e.stopPropagation(); + return false; + } + + this.shadowRoot.getElementById('sendTo').addEventListener('keydown', stopKeyEventPropagation); + this.shadowRoot.getElementById('messageBox').addEventListener('keydown', stopKeyEventPropagation); + + const getDataFromURL = () => { + let tempUrl = document.location.href + let splitedUrl = decodeURI(tempUrl).split('?') + let urlData = splitedUrl[1] + if (urlData !== undefined) { + this.chatId = urlData + } + } + + 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.request('apiCall', { + url: `/addresses/balance/${window.parent.reduxStore.getState().app.selectedAddress.address}` + }).then(res => { + this.balance = res + }) + }) + + parentEpml.imReady() + } + + _sendMessage() { + + this.isLoading = true + + const recipient = this.shadowRoot.getElementById('sendTo').value + const messageBox = this.shadowRoot.getElementById('messageBox') + const messageText = messageBox.value + + if (recipient.length === 0) { + this.isLoading = false + } else if (messageText.length === 0) { + this.isLoading = false + } else { + this.sendMessage() + } + } + + async sendMessage(e) { + this.isLoading = true + + const _recipient = this.shadowRoot.getElementById('sendTo').value + const messageBox = this.shadowRoot.getElementById('messageBox') + const messageText = messageBox.value + let recipient + + const validateName = async (receiverName) => { + let myRes + let myNameRes = await parentEpml.request('apiCall', { + type: 'api', + url: `/names/${receiverName}` + }) + + if (myNameRes.error === 401) { + myRes = false + } else { + myRes = myNameRes + } + + return myRes + } + + const myNameRes = await validateName(_recipient) + if (!myNameRes) { + + recipient = _recipient + } else { + + recipient = myNameRes.owner + } + + let _reference = new Uint8Array(64); + window.crypto.getRandomValues(_reference); + + let sendTimestamp = Date.now() + + let reference = window.parent.Base58.encode(_reference) + + const getAddressPublicKey = async () => { + let isEncrypted + let _publicKey + + let addressPublicKey = await parentEpml.request('apiCall', { + type: 'api', + url: `/addresses/publickey/${recipient}` + }) + + + if (addressPublicKey.error === 102) { + _publicKey = false + // Do something here... + parentEpml.request('showSnackBar', "Invalid Name / Address, Check the name / address and retry...") + this.isLoading = false + } else if (addressPublicKey !== false) { + isEncrypted = 1 + _publicKey = addressPublicKey + sendMessageRequest(isEncrypted, _publicKey) + } else { + isEncrypted = 0 + _publicKey = this.selectedAddress.address + sendMessageRequest(isEncrypted, _publicKey) + } + }; + + const sendMessageRequest = async (isEncrypted, _publicKey) => { + + let chatResponse = await parentEpml.request('chat', { + type: 18, + nonce: this.selectedAddress.nonce, + params: { + timestamp: sendTimestamp, + recipient: recipient, + recipientPublicKey: _publicKey, + message: messageText, + lastReference: reference, + proofOfWorkNonce: 0, + isEncrypted: isEncrypted, + isText: 1 + } + }) + + _computePow(chatResponse) + } + + const _computePow = async (chatBytes) => { + + const _chatBytesArray = Object.keys(chatBytes).map(function (key) { return chatBytes[key]; }); + const chatBytesArray = new Uint8Array(_chatBytesArray) + const chatBytesHash = new window.parent.Sha256().process(chatBytesArray).finish().result + const hashPtr = window.parent.sbrk(32, window.parent.heap); + const hashAry = new Uint8Array(window.parent.memory.buffer, hashPtr, 32); + hashAry.set(chatBytesHash); + + const difficulty = this.balance === 0 ? 14 : 8; + + const workBufferLength = 8 * 1024 * 1024; + const workBufferPtr = window.parent.sbrk(workBufferLength, window.parent.heap); + + let nonce = window.parent.computePow(hashPtr, workBufferPtr, workBufferLength, difficulty) + + let _response = await parentEpml.request('sign_chat', { + nonce: this.selectedAddress.nonce, + chatBytesArray: chatBytesArray, + chatNonce: nonce + }) + + getSendChatResponse(_response) + } + + const getSendChatResponse = (response) => { + + if (response === true) { + messageBox.value = "" + parentEpml.request('showSnackBar', "Message Sent Successfully!") + this.isLoading = false + } else if (response.error) { + parentEpml.request('showSnackBar', response.message) + this.isLoading = false + } else { + parentEpml.request('showSnackBar', "Sending failed, Please retry...") + this.isLoading = false + } + + } + getAddressPublicKey() + } + + _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() + } + + _textArea(e) { + if (e.keyCode === 13 && !e.shiftKey) this._sendMessage() + } +} + +window.customElements.define('chat-welcome-page', ChatWelcomePage) diff --git a/qortal-ui-plugins/plugins/core/components/TimeAgo.js b/qortal-ui-plugins/plugins/core/components/TimeAgo.js new file mode 100644 index 00000000..b5231e29 --- /dev/null +++ b/qortal-ui-plugins/plugins/core/components/TimeAgo.js @@ -0,0 +1,53 @@ +import { LitElement, html, css } from 'lit-element' + +import '@github/time-elements' + +class TimeAgo extends LitElement { + static get properties() { + return { + selectedAddress: { type: Object }, + config: { type: Object }, + timestamp: { type: Number }, + format: { type: String, reflect: true }, + timeIso: { type: String } + } + } + + static get styles() { + return css`` + } + + updated(changedProps) { + changedProps.forEach((OldProp, name) => { + if (name === 'timeIso') { + this.renderTime(this.timestamp) + } + }); + + this.shadowRoot.querySelector('time-ago').setAttribute('title', ''); + } + + constructor() { + super(); + this.timestamp = 0 + this.timeIso = '' + this.format = '' + } + + render() { + + return html` + + ` + } + + renderTime(timestamp) { + timestamp === undefined ? this.timeIso = '' : this.timeIso = new Date(timestamp).toISOString(); + } + + firstUpdated() { + // ... + } +} + +window.customElements.define('message-time', TimeAgo) diff --git a/qortal-ui-plugins/plugins/core/components/ToolTip.js b/qortal-ui-plugins/plugins/core/components/ToolTip.js new file mode 100644 index 00000000..14afe03b --- /dev/null +++ b/qortal-ui-plugins/plugins/core/components/ToolTip.js @@ -0,0 +1,135 @@ +import { LitElement, html, css } from 'lit-element' +import { Epml } from '../../../epml.js' + + +const parentEpml = new Epml({ type: 'WINDOW', source: window.parent }) + +class ToolTip extends LitElement { + static get properties() { + return { + selectedAddress: { type: Object }, + config: { type: Object }, + toolTipMessage: { type: String, reflect: true }, + showToolTip: { type: Boolean, reflect: true } + } + } + + static get styles() { + return css` + .tooltip { + position: relative; + display: inline-block; + border-bottom: 1px dotted black; + } + + .tooltiptext { + margin-bottom: 100px; + display: inline; + visibility: visible; + width: 120px; + background-color: #555; + color: #fff; + text-align: center; + border-radius: 6px; + padding: 5px 0; + position: absolute; + z-index: 1; + bottom: 125%; + left: 50%; + margin-left: -60px; + opacity: 1; + transition: opacity 0.3s; + } + + .tooltiptext::after { + content: ""; + position: absolute; + top: 100%; + left: 50%; + margin-left: -5px; + border-width: 5px; + border-style: solid; + border-color: #555 transparent transparent transparent; + } + + .hide-tooltip { + display: none; + visibility: hidden; + opacity: 0; + } + ` + } + + // attributeChangedCallback(name, oldVal, newVal) { + // console.log('attribute change: ', name, newVal.address); + // super.attributeChangedCallback(name, oldVal, newVal); + // } + + constructor() { + super() + this.selectedAddress = {} + this.config = { + user: { + node: { + + } + } + } + this.toolTipMessage = '' + this.showToolTip = false + } + + render() { + + console.log(this.toolTipMessage, "tool"); + + return html` + ${this.toolTipMessage} + ` + } + + + // { + // "type": "CHAT", + // "timestamp": 1589189772000, + // "reference": "1111111111111111111111111111111111111111111111111111111111111111", + // "fee": "0.00000000", + // "signature": "7gXr4sZ3W6Lq7sRHwoxQ6nEq4LvV7aiVkhfi2xtsf6v1P4M2v4oYptMowRXvbtEhJQfg2wfr3BMDmhCEcrAENRn", + // "txGroupId": 0, + // "approvalStatus": "NOT_REQUIRED", + // "creatorAddress": "QdevPHFK86KNuzoYKLqFz7DPkr2x4juzvi", + // "senderPublicKey": "31J8KD24kFbNtdrQg5iUEHXGxGSxKC9jxLDakE1QChyG", + // "sender": "QdevPHFK86KNuzoYKLqFz7DPkr2x4juzvi", + // "nonce": 89955, + // "data": "35sYULUFnjz7SCRSb", + // "isText": false, + // "isEncrypted": false + // } + + firstUpdated() { + 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(getGroupIdFromURL, 1) + configLoaded = true + } + this.config = JSON.parse(c) + }) + }) + + + parentEpml.imReady() + } + + +} + +window.customElements.define('tool-tip', ToolTip)