diff --git a/qortal-ui-core/language/us.json b/qortal-ui-core/language/us.json index 9dd5365e..8f0b9c8e 100644 --- a/qortal-ui-core/language/us.json +++ b/qortal-ui-core/language/us.json @@ -509,7 +509,8 @@ "bcchange9": "Private Message", "bcchange10": "More", "bcchange11": "Reply", - "bcchange12": "Edit" + "bcchange12": "Edit", + "bcchange13": "Reaction" }, "grouppage": { "gchange1": "Qortal Groups", diff --git a/qortal-ui-plugins/package.json b/qortal-ui-plugins/package.json index 5e2e61cf..c81ac3c8 100644 --- a/qortal-ui-plugins/package.json +++ b/qortal-ui-plugins/package.json @@ -19,8 +19,10 @@ "dependencies": { "@material/mwc-list": "0.27.0", "@material/mwc-select": "0.27.0", + "compressorjs": "^1.1.1", "emoji-picker-js": "https://github.com/Qortal/emoji-picker-js", - "localforage": "^1.10.0" + "localforage": "^1.10.0", + "short-unique-id": "^4.4.4" }, "devDependencies": { "@babel/core": "7.19.3", diff --git a/qortal-ui-plugins/plugins/core/components/ChatPage.js b/qortal-ui-plugins/plugins/core/components/ChatPage.js index 68462224..2bd6b712 100644 --- a/qortal-ui-plugins/plugins/core/components/ChatPage.js +++ b/qortal-ui-plugins/plugins/core/components/ChatPage.js @@ -6,6 +6,8 @@ import localForage from "localforage"; registerTranslateConfig({ loader: lang => fetch(`/language/${lang}.json`).then(res => res.json()) }) +import ShortUniqueId from 'short-unique-id'; +import Compressor from 'compressorjs'; import { escape, unescape } from 'html-escaper'; import { inputKeyCodes } from '../../utils/keyCodes.js' @@ -20,6 +22,7 @@ import '@material/mwc-button' import '@material/mwc-dialog' import '@material/mwc-icon' import { replaceMessagesEdited } from '../../utils/replace-messages-edited.js'; +import { publishData } from '../../utils/publish-image.js'; const messagesCache = localForage.createInstance({ name: "messages-cache", @@ -53,8 +56,9 @@ class ChatPage extends LitElement { messagesRendered: { type: Array }, repliedToMessageObj: { type: Object }, editedMessageObj: { type: Object }, - chatMessageSize: { type: String }, - iframeHeight: { type: Number } + iframeHeight: { type: Number }, + chatMessageSize: { type: Number}, + imageFile: {type: Object} } } @@ -197,6 +201,8 @@ class ChatPage extends LitElement { super() this.getOldMessage = this.getOldMessage.bind(this) this._sendMessage = this._sendMessage.bind(this) + this.insertImage = this.insertImage.bind(this) + this.getMessageSize = this.getMessageSize.bind(this) this._downObserverhandler = this._downObserverhandler.bind(this) this.calculateIFrameHeight = this.calculateIFrameHeight.bind(this) this.selectedAddress = {} @@ -219,7 +225,10 @@ class ChatPage extends LitElement { this.messagesRendered = [] this.repliedToMessageObj = null this.editedMessageObj = null - this.iframeHeight = 40; + this.iframeHeight = 40 + this.chatMessageSize = 5 + this.imageFile = null + this.uid = new ShortUniqueId() } render() { @@ -227,6 +236,46 @@ class ChatPage extends LitElement {
${this.isLoadingMessages ? html`

${translate("chatpage.cchange22")}

` : this.renderChatScroller(this._initialMessages)} + +
+ +
+
+ hello + ${this.imageFile && html` + + `} + +
+ { + + this._sendMessage({ + type: 'image', + imageFile: this.imageFile, + caption: 'This is a caption' + + + }) + }} + > + send + + { + + this.imageFile = null + }} + > + ${translate("general.close")} + +
@@ -289,7 +338,18 @@ class ChatPage extends LitElement { ` } + + + insertImage(file){ + this.imageFile = file + + + } + + + async firstUpdated() { + // TODO: Load and fetch messages from localstorage (maybe save messages to localstorage...) // this.changeLanguage(); this.emojiPickerHandler = this.shadowRoot.querySelector('.emoji-button'); @@ -638,11 +698,10 @@ class ChatPage extends LitElement { } - const stringified = JSON.stringify(messageObject) const size = new Blob([stringified]).size; this.chatMessageSize = size - + } catch (error) { console.error(error) @@ -1001,7 +1060,7 @@ class ChatPage extends LitElement { // Add to the messages... TODO: Save messages to localstorage and fetch from it to make it persistent... } - _sendMessage(outSideMsg) { + async _sendMessage(outSideMsg) { // have params to determine if it's a reply or not // have variable to determine if it's a response, holds signature in constructor // need original message signature @@ -1016,8 +1075,161 @@ class ChatPage extends LitElement { // Format and Sanitize Message const sanitizedMessage = messageText.replace(/ /gi, ' ').replace(//gi, '\n'); const trimmedMessage = sanitizedMessage.trim(); + + const getName = async (recipient)=> { + try { + + const getNames = await parentEpml.request("apiCall", { + type: "api", + url: `/names/address/${recipient}`, + }) + if(Array.isArray(getNames) && getNames.length > 0 ){ + return getNames[0].name + } else { + return '' + } + } catch (error) { + return "" + } + } - if(outSideMsg && outSideMsg.type === 'reaction'){ + if(outSideMsg && outSideMsg.type === 'delete'){ + const userName = outSideMsg.name + const identifier = outSideMsg.identifier + let compressedFile = '' + var str = + "iVBORw0KGgoAAAANSUhEUgAAAsAAAAGMAQMAAADuk4YmAAAAA1BMVEX///+nxBvIAAAAAXRSTlMAQObYZgAAADlJREFUeF7twDEBAAAAwiD7p7bGDlgYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwAGJrAABgPqdWQAAAABJRU5ErkJggg=="; + + const b64toBlob = (b64Data, contentType='', sliceSize=512) => { + const byteCharacters = atob(b64Data); + const byteArrays = []; + + for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) { + const slice = byteCharacters.slice(offset, offset + sliceSize); + + const byteNumbers = new Array(slice.length); + for (let i = 0; i < slice.length; i++) { + byteNumbers[i] = slice.charCodeAt(i); + } + + const byteArray = new Uint8Array(byteNumbers); + byteArrays.push(byteArray); + } + + const blob = new Blob(byteArrays, {type: contentType}); + return blob; + } + const blob = b64toBlob(str, 'image/png'); + + await new Promise(resolve =>{ + new Compressor( blob, { + quality: 0.6, + maxWidth: 500, + success(result){ + console.log({result}) + const file = new File([result], "name", { + type: 'image/png' + }); + console.log({file}) + compressedFile = file + resolve() + }, + error(err) { + console.log(err.message); + }, + }) + }) + try { + console.log({userName, compressedFile, identifier, selectedAddress: this.selectedAddress}) + await publishData({ + registeredName: userName , + file : compressedFile , + service: 'IMAGE', + identifier : identifier, + parentEpml, + metaData: undefined, + uploadType: 'file', + selectedAddress: this.selectedAddress + }) + } catch (error) { + console.error(error) + } + + + + typeMessage = 'edit' + let chatReference = outSideMsg.editedMessageObj.reference + + if(outSideMsg.editedMessageObj.chatReference){ + chatReference = outSideMsg.editedMessageObj.chatReference + } + + let message = "" + try { + const parsedMessageObj = JSON.parse(outSideMsg.editedMessageObj.decodedMessage) + message = parsedMessageObj + + } catch (error) { + message = outSideMsg.editedMessageObj.decodedMessage + } + const messageObject = { + ...message, + isImageDeleted: true + } + const stringifyMessageObject = JSON.stringify(messageObject) + this.sendMessage(stringifyMessageObject, typeMessage, chatReference); + + + } + else if(outSideMsg && outSideMsg.type === 'image'){ + const userName = await getName(this.selectedAddress.address) + const id = this.uid(); + const identifier = `qchat_${id}` + let compressedFile = '' + await new Promise(resolve =>{ + new Compressor( outSideMsg.imageFile, { + quality: 0.6, + maxWidth: 500, + success(result){ + const file = new File([result], "name", { + type: outSideMsg.imageFile.type + }); + compressedFile = file + resolve() + }, + error(err) { + console.log(err.message); + }, + }) + }) + + await publishData({ + registeredName: userName , + file : compressedFile , + service: 'IMAGE', + identifier : identifier, + parentEpml, + metaData: undefined, + uploadType: 'file', + selectedAddress: this.selectedAddress + }) + const messageObject = { + messageText: outSideMsg.caption, + images: [{ + service: "IMAGE", + name: userName, + identifier: identifier + }], + isImageDeleted: false, + repliedTo: '', + version: 1 + } + const stringifyMessageObject = JSON.stringify(messageObject) + this.sendMessage(stringifyMessageObject, typeMessage); + + + + } else if(outSideMsg && outSideMsg.type === 'reaction'){ typeMessage = 'edit' let chatReference = outSideMsg.editedMessageObj.reference @@ -1271,10 +1483,12 @@ class ChatPage extends LitElement { return arr.length === 0 } + + initChatEditor() { const ChatEditor = function (editorConfig) { - + const ChatEditor = function () { const editor = this; editor.init(); @@ -1421,11 +1635,14 @@ class ChatPage extends LitElement { editor.mirror.value = unescapedValue; }; - ChatEditor.prototype.listenChanges = function () { + 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) { + const events = ['drop', 'contextmenu', 'mouseup', 'click', 'touchend', 'keydown', 'blur', 'paste'] + + for (let i = 0; i < events.length; i++) { + const event = events[i] + editor.content.body.addEventListener(event, async function (e) { editorConfig.getMessageSize(editorConfig.mirrorElement.value) if (e.type === 'click') { @@ -1435,10 +1652,29 @@ class ChatPage extends LitElement { } if (e.type === 'paste') { + e.preventDefault(); - + const item_list = await navigator.clipboard.read(); + let image_type; // we will feed this later + const item = item_list.find( item => // choose the one item holding our image + item.types.some( type => { // does this item have our type + if( type.startsWith( 'image/' ) ) { + image_type = type; // store which kind of image type it is + return true; + } + } ) + ); + const blob = item && await item.getType( image_type ); + var file = new File([blob], "name", { + type: image_type + }); + + editorConfig.insertImage(file) + + + navigator.clipboard.readText().then(clipboardText => { - + let escapedText = editorConfig.escape(clipboardText); editor.insertText(escapedText); @@ -1508,7 +1744,7 @@ class ChatPage extends LitElement { editor.updateMirror(); }); - }); + } editor.content.addEventListener('click', function (event) { @@ -1548,7 +1784,10 @@ class ChatPage extends LitElement { emojiPicker: this.emojiPicker, escape: escape, unescape: unescape, - placeholder: this.chatEditorPlaceholder + placeholder: this.chatEditorPlaceholder, + imageFile: this.imageFile, + requestUpdate: this.requestUpdate, + insertImage: this.insertImage }; this.chatEditor = new ChatEditor(editorConfig); } diff --git a/qortal-ui-plugins/plugins/core/components/ChatScroller-css.js b/qortal-ui-plugins/plugins/core/components/ChatScroller-css.js index 65ea615a..4609de6c 100644 --- a/qortal-ui-plugins/plugins/core/components/ChatScroller-css.js +++ b/qortal-ui-plugins/plugins/core/components/ChatScroller-css.js @@ -329,4 +329,22 @@ export const chatStyles = css` margin-right: 10px; cursor: pointer } + + .image-container { + display: flex; + } + .image-delete-icon { + margin-left: 5px; + height: 20px; + cursor: pointer; + visibility: hidden; + transition: .2s all; + opacity: .8 + } + .image-delete-icon:hover { + opacity: 1 + } + .message-parent:hover .image-delete-icon { + visibility: visible; + } ` diff --git a/qortal-ui-plugins/plugins/core/components/ChatScroller.js b/qortal-ui-plugins/plugins/core/components/ChatScroller.js index 5fe748a8..be2c9acb 100644 --- a/qortal-ui-plugins/plugins/core/components/ChatScroller.js +++ b/qortal-ui-plugins/plugins/core/components/ChatScroller.js @@ -210,19 +210,26 @@ class MessageTemplate extends LitElement { let message = "" let reactions = [] let repliedToData = null + let image = null + let isImageDeleted = false try { const parsedMessageObj = JSON.parse(this.messageObj.decodedMessage) message = parsedMessageObj.messageText repliedToData = this.messageObj.repliedToData + isImageDeleted = parsedMessageObj.isImageDeleted reactions = parsedMessageObj.reactions || [] - + if(parsedMessageObj.images && Array.isArray(parsedMessageObj.images) && parsedMessageObj.images.length > 0){ + image = parsedMessageObj.images[0] + } } catch (error) { message = this.messageObj.decodedMessage } let avatarImg = '' + let imageHTML = '' let nameMenu = '' let levelFounder = '' let hideit = hidemsg.includes(this.messageObj.sender) + levelFounder = html`` @@ -234,6 +241,13 @@ class MessageTemplate extends LitElement { } else { avatarImg = html`` } + + if(image){ + 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 imageUrl = `${nodeUrl}/arbitrary/${image.service}/${image.name}/${image.identifier}?async=true&apiKey=${myNode.apiKey}` + imageHTML = html`` + } if (this.messageObj.sender === this.myAddress) { @@ -268,9 +282,21 @@ class MessageTemplate extends LitElement {

${repliedToData.decodedMessage.messageText}

`} + ${image && !isImageDeleted ? html` +
+ ${imageHTML} this.sendMessage({ + type: 'delete', + name: image.name, + identifier: image.identifier, + editedMessageObj: this.messageObj, + + })} + class="image-delete-icon" icon="vaadin:close" slot="icon"> +
+ ` : html``}
${unsafeHTML(this.emojiPicker.parse(this.escapeHTML(message)))} -
${reactions.map((reaction)=> { @@ -387,6 +413,15 @@ class ChatMenu extends LitElement { render() { return html`
+ @@ -402,15 +437,7 @@ class ChatMenu extends LitElement { }}">
- + ${this.myAddress === this.originalMessage.sender ? ( html`
{ + const myNode = + window.parent.reduxStore.getState().app.nodeConfig.knownNodes[ + window.parent.reduxStore.getState().app.nodeConfig.node + ] + let apiKey = myNode.apiKey + return apiKey +} + +export const publishData = async ({ + registeredName, + path, + file, + service, + identifier, + parentEpml, + metaData, + uploadType, + selectedAddress, +}) => { + const validateName = async (receiverName) => { + let nameRes = await parentEpml.request("apiCall", { + type: "api", + url: `/names/${receiverName}`, + }) + + return nameRes + } + + 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) { + return + } + + 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 + ) + let nonce = window.parent.computePow( + hashPtr, + workBufferPtr, + workBufferLength, + difficulty + ) + let response = await parentEpml.request("sign_arbitrary", { + nonce: selectedAddress.nonce, + arbitraryBytesBase58: transactionBytesBase58, + arbitraryBytesForSigningBase58: convertedBytesBase58, + arbitraryNonce: nonce, + }) + let myResponse = { error: "" } + if (response === false) { + return + } else { + myResponse = response + } + + return myResponse + } + + const validate = async () => { + let validNameRes = await validateName(registeredName) + if (validNameRes.error) { + return + } + let transactionBytes = await uploadData(registeredName, path, file) + if (transactionBytes.error) { + return + } else if ( + transactionBytes.includes("Error 500 Internal Server Error") + ) { + return + } + + let signAndProcessRes = await signAndProcess(transactionBytes) + if (signAndProcessRes.error) { + return + } + } + + const uploadData = async (registeredName, path, file) => { + if (identifier != null && identifier.trim().length > 0) { + 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 (uploadType === "zip") { + urlSuffix = "/zip" + } + // If we're sending file data, use the /base64 version of the POST /arbitrary/* API + else if (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(metaData.title || "") + // let description = encodeURIComponent(metaData.description || "") + // let category = encodeURIComponent(metaData.category || "") + // let tag1 = encodeURIComponent(metaData.tag1 || "") + // let tag2 = encodeURIComponent(metaData.tag2 || "") + // let tag3 = encodeURIComponent(metaData.tag3 || "") + // let tag4 = encodeURIComponent(metaData.tag4 || "") + // let tag5 = encodeURIComponent(metaData.tag5 || "") + + // let metadataQueryString = `title=${title}&description=${description}&category=${category}&tags=${tag1}&tags=${tag2}&tags=${tag3}&tags=${tag4}&tags=${tag5}` + + let uploadDataUrl = `/arbitrary/${service}/${registeredName}${urlSuffix}?apiKey=${getApiKey()}` + if (identifier != null && identifier.trim().length > 0) { + uploadDataUrl = `/arbitrary/${service}/${registeredName}/${identifier}${urlSuffix}?apiKey=${getApiKey()}` + } + let uploadDataRes = await parentEpml.request("apiCall", { + type: "api", + method: "POST", + url: `${uploadDataUrl}`, + body: `${postBody}`, + }) + return uploadDataRes + } + + + + + + } + await validate() +}