4
1
mirror of https://github.com/Qortal/qortal-ui.git synced 2025-02-11 17:55:51 +00:00

Merge branch 'feature/implement-logic-edit-reply-messages' of https://github.com/PhillipLangMartinez/qortal-ui into feature/implement-UI-edit-reply-messages

This commit is contained in:
Justin Ferrari 2022-11-01 08:31:56 -05:00
commit 5a096126ad
6 changed files with 481 additions and 28 deletions

View File

@ -509,7 +509,8 @@
"bcchange9": "Private Message",
"bcchange10": "More",
"bcchange11": "Reply",
"bcchange12": "Edit"
"bcchange12": "Edit",
"bcchange13": "Reaction"
},
"grouppage": {
"gchange1": "Qortal Groups",

View File

@ -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",

View File

@ -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 {
<div class="chat-container">
<div>
${this.isLoadingMessages ? html`<h1>${translate("chatpage.cchange22")}</h1>` : this.renderChatScroller(this._initialMessages)}
<mwc-dialog id="showDialogPublicKey" ?open=${this.imageFile}>
<div class="dialog-header" >
</div>
<div class="dialog-container">
hello
${this.imageFile && html`
<img src=${URL.createObjectURL(this.imageFile)} />
`}
</div>
<mwc-button
slot="primaryAction"
dialogAction="cancel"
class="red"
@click=${()=>{
this._sendMessage({
type: 'image',
imageFile: this.imageFile,
caption: 'This is a caption'
})
}}
>
send
</mwc-button>
<mwc-button
slot="primaryAction"
dialogAction="cancel"
class="red"
@click=${()=>{
this.imageFile = null
}}
>
${translate("general.close")}
</mwc-button>
</mwc-dialog>
</div>
<div class="chat-text-area" style="${`height: ${this.iframeHeight}px`}">
<div class="typing-area">
@ -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(/&nbsp;/gi, ' ').replace(/<br\s*[\/]?>/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);
}

View File

@ -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;
}
`

View File

@ -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`<level-founder checkleveladdress="${this.messageObj.sender}"></level-founder>`
@ -234,6 +241,13 @@ class MessageTemplate extends LitElement {
} else {
avatarImg = html`<img src='/img/incognito.png' style="max-width:100%; max-height:100%;" onerror="this.onerror=null;" />`
}
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`<img src="${imageUrl}" style="max-width:45vh; max-height:40vh; border-radius: 5px" onerror="this.onerror=null; this.src='/img/incognito.png';" />`
}
if (this.messageObj.sender === this.myAddress) {
@ -268,9 +282,21 @@ class MessageTemplate extends LitElement {
<p class="replied-message">${repliedToData.decodedMessage.messageText}</p>
</div>
`}
${image && !isImageDeleted ? html`
<div class="image-container">
${imageHTML}<vaadin-icon
@click=${() => this.sendMessage({
type: 'delete',
name: image.name,
identifier: image.identifier,
editedMessageObj: this.messageObj,
})}
class="image-delete-icon" icon="vaadin:close" slot="icon"></vaadin-icon>
</div>
` : html``}
<div id="messageContent" class="message">
${unsafeHTML(this.emojiPicker.parse(this.escapeHTML(message)))}
</div>
<div>
${reactions.map((reaction)=> {
@ -387,6 +413,15 @@ class ChatMenu extends LitElement {
render() {
return html`
<div class="container">
<div
class="menu-icon tooltip reaction"
data-text="${translate("blockpage.bcchange13")}"
@click=${(e) => {
this.emojiPicker.togglePicker(e.target)
}}
>
<vaadin-icon icon="vaadin:smiley-o" slot="icon"></vaadin-icon>
</div>
<div class="menu-icon tooltip" data-text="${translate("blockpage.bcchange9")}" @click="${() => this.showPrivateMessageModal()}">
<vaadin-icon icon="vaadin:paperplane" slot="icon"></vaadin-icon>
</div>
@ -402,15 +437,7 @@ class ChatMenu extends LitElement {
}}">
<vaadin-icon icon="vaadin:reply" slot="icon"></vaadin-icon>
</div>
<div
class="menu-icon tooltip reaction"
data-text="${translate("blockpage.bcchange13")}"
@click=${(e) => {
this.emojiPicker.togglePicker(e.target)
}}
>
<vaadin-icon icon="vaadin:smiley-o" slot="icon"></vaadin-icon>
</div>
${this.myAddress === this.originalMessage.sender ? (
html`
<div

View File

@ -0,0 +1,166 @@
const getApiKey = () => {
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()
}