import QtQuick 2.13
import QtQuick.Controls 2.13
import QtQuick.Layouts 1.13
import QtQuick.Dialogs 1.3
import DotherSide 0.1
import utils 1.0
import shared 1.0
import shared.panels 1.0
import shared.popups 1.0
import shared.stores 1.0
//TODO remove this dependency
import AppLayouts.Chat.panels 1.0
import StatusQ.Core 0.1
import StatusQ.Core.Theme 0.1
import StatusQ.Core.Utils 0.1 as StatusQUtils
import StatusQ.Components 0.1
import StatusQ.Controls 0.1 as StatusQ
Rectangle {
id: control
signal sendTransactionCommandButtonClicked()
signal receiveTransactionCommandButtonClicked()
signal stickerSelected(string hashId, string packId, string url)
signal sendMessage(var event)
signal unblockChat()
property var usersStore
property var store
property var emojiPopup: null
property var stickersPopup: null
// Use this to only enable the Connections only when this Input opens the Emoji popup
property bool emojiPopupOpened: false
property bool stickersPopupOpened: false
property bool closeGifPopupAfterSelection: true
property bool emojiEvent: false
property bool isColonPressed: false
property bool isReply: false
property string replyMessageId: replyArea.messageId
property bool isImage: false
property bool isEdit: false
property bool isContactBlocked: false
property bool isActiveChannel: false
property int messageLimit: 2000
property int messageLimitVisible: 200
property int chatType
property string chatInputPlaceholder: qsTr("Message")
property alias textInput: messageInputField
property var fileUrlsAndSources: []
property var imageErrorMessageLocation: StatusChatInput.ImageErrorMessageLocation.Top // TODO: Remove this proeprty?
property var messageContextMenu
property alias suggestions: suggestionsBox
enum ImageErrorMessageLocation {
Top,
Bottom
}
objectName: "statusChatInput"
function parseMessage(message) {
let mentionsMap = new Map()
let index = 0
while (true) {
index = message.indexOf("", index) + 4
if (endIndex < 0) {
index += 8 // " '
mentionsMap.set(mentionLink, mentionTag)
index += linkTag.length
}
let text = message;
for (let [key, value] of mentionsMap)
text = text.replace(new RegExp(key, 'g'), value)
textInput.text = text
textInput.cursorPosition = textInput.length
}
implicitWidth: layout.implicitWidth + layout.anchors.leftMargin + layout.anchors.rightMargin
implicitHeight: layout.implicitHeight + layout.anchors.topMargin + layout.anchors.bottomMargin
color: Style.current.transparent
QtObject {
id: d
//mentions helper properties
property string copiedTextPlain: ""
property string copiedTextFormatted: ""
property var copiedMentionsPos: []
property int copyTextStart: 0
// set to true when pasted text comes from this component (was copied within this component)
property bool internalPaste: false
property int leftOfMentionIndex: -1
property int rightOfMentionIndex: -1
readonly property StateGroup emojiPopupTakeover: StateGroup {
states: State {
when: control.emojiPopupOpened
PropertyChanges {
target: emojiPopup
parent: control
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutsideParent
x: control.width - emojiPopup.width - Style.current.halfPadding
y: -emojiPopup.height
}
}
}
readonly property StateGroup stickersPopupTakeover: StateGroup {
states: State {
when: control.stickersPopupOpened
PropertyChanges {
target: stickersPopup
parent: control
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutsideParent
x: control.width - stickersPopup.width - Style.current.halfPadding
y: -stickersPopup.height
}
}
}
function copyMentions(start, end) {
copiedMentionsPos = []
for (let k = 0; k < mentionsPos.length; k++) {
if (mentionsPos[k].leftIndex >= start && mentionsPos[k].rightIndex <= end) {
const mention = {
name: mentionsPos[k].name,
pubKey: mentionsPos[k].pubKey,
leftIndex: mentionsPos[k].leftIndex - start,
rightIndex: mentionsPos[k].rightIndex - start
}
copiedMentionsPos.push(mention)
}
}
}
function sortMentions() {
if (mentionsPos.length < 2) {
return
}
mentionsPos = mentionsPos.sort(function(a, b){
return a.leftIndex - b.leftIndex
})
}
function updateMentionsPositions() {
if (mentionsPos.length == 0) {
return
}
const unformattedText = messageInputField.getText(0, messageInputField.length)
if (!unformattedText.includes("@")) {
return
}
const keyEvent = messageInputField.keyEvent
if ((keyEvent.key === Qt.Key_Right) || (keyEvent.key === Qt.Key_Left)
|| (keyEvent.key === Qt.Key_Up) || (keyEvent.key === Qt.Key_Down)) {
return
}
let lastRightIndex = -1
for (var k = 0; k < mentionsPos.length; k++) {
const aliasIndex = unformattedText.indexOf(mentionsPos[k].name, lastRightIndex)
if (aliasIndex === -1) {
continue
}
lastRightIndex = aliasIndex + mentionsPos[k].name.length
if (aliasIndex - 1 !== mentionsPos[k].leftIndex) {
mentionsPos[k].leftIndex = aliasIndex - 1
mentionsPos[k].rightIndex = aliasIndex + mentionsPos[k].name.length
}
}
}
}
function insertInTextInput(start, text) {
// Repace new lines with entities because `insert` gets rid of them
messageInputField.insert(start, text.replace(/\n/g, "
"));
}
function togglePopup(popup, btn) {
if (popup !== control.stickersPopup) {
control.stickersPopup.close()
}
if (popup !== gifPopup) {
gifPopup.close()
}
if (popup !== emojiPopup) {
emojiPopup.close()
}
if (popup.opened) {
popup.close()
btn.highlighted = false
} else {
popup.open()
btn.highlighted = true
}
}
Connections {
enabled: control.emojiPopupOpened
target: emojiPopup
onEmojiSelected: function (text, atCursor) {
insertInTextInput(atCursor ? messageInputField.cursorPosition : messageInputField.length, text)
emojiBtn.highlighted = false
messageInputField.forceActiveFocus();
}
onClosed: {
emojiBtn.highlighted = false
control.emojiPopupOpened = false
}
}
Connections {
enabled: control.stickersPopupOpened
target: control.stickersPopup
onStickerSelected: {
control.stickerSelected(hashId, packId, url)
control.hideExtendedArea();
messageInputField.forceActiveFocus();
}
onClosed: {
stickersBtn.highlighted = false
control.stickersPopupOpened = false
}
}
property var mentionsPos: []
function insertMention(aliasName, publicKey, lastAtPosition, lastCursorPosition) {
let startInd = aliasName.indexOf("(");
if (startInd > 0){
aliasName = aliasName.substring(0, startInd-1)
}
const hasEmoji = StatusQUtils.Emoji.hasEmoji(messageInputField.text)
const spanPlusAlias = `${Constants.mentionSpanTag}@${aliasName} `;
let rightIndex = hasEmoji ? lastCursorPosition + 2 : lastCursorPosition
messageInputField.remove(lastAtPosition, rightIndex)
messageInputField.insert(lastAtPosition, spanPlusAlias)
messageInputField.cursorPosition = lastAtPosition + aliasName.length + 2;
if (messageInputField.cursorPosition === 0) {
// It reset to 0 for some reason, go back to the end
messageInputField.cursorPosition = messageInputField.length
}
mentionsPos.push({name: aliasName, pubKey: publicKey, leftIndex: lastAtPosition, rightIndex: (lastAtPosition+aliasName.length + 1)});
d.sortMentions()
}
function isUploadFilePressed(event) {
return (event.key === Qt.Key_U) && (event.modifiers & Qt.ControlModifier) && imageBtn.visible && !imageDialog.visible
}
function checkTextInsert() {
if (emojiSuggestions.visible) {
replaceWithEmoji(extrapolateCursorPosition(), emojiSuggestions.shortname, emojiSuggestions.unicode);
return true
}
if (suggestionsBox.visible) {
suggestionsBox.selectCurrentItem();
return true
}
return false
}
function onKeyPress(event) {
if (event.modifiers === Qt.NoModifier && (event.key === Qt.Key_Enter || event.key === Qt.Key_Return)) {
if (checkTextInsert()) {
event.accepted = true;
return
}
if (messageInputField.length <= messageLimit) {
control.sendMessage(event)
control.hideExtendedArea();
event.accepted = true
return;
}
if (event) {
event.accepted = true
messageTooLongDialog.open()
}
} else if (event.key === Qt.Key_Escape && control.isReply) {
control.isReply = false
event.accepted = true
}
const symbolPressed = event.text.length > 0 &&
event.key !== Qt.Key_Backspace &&
event.key !== Qt.Key_Delete &&
event.key !== Qt.Key_Escape
if (mentionsPos.length > 0 && symbolPressed) {
for (var i = 0; i < mentionsPos.length; i++) {
if (messageInputField.cursorPosition === mentionsPos[i].leftIndex) {
d.leftOfMentionIndex = i
event.accepted = true
return
} else if (messageInputField.cursorPosition === mentionsPos[i].rightIndex) {
d.rightOfMentionIndex = i
event.accepted = true
return
}
}
}
if (event.key === Qt.Key_Tab) {
if (checkTextInsert()) {
event.accepted = true;
return
}
}
const message = control.extrapolateCursorPosition();
// handle new line in blockquote
if ((event.key === Qt.Key_Enter || event.key === Qt.Key_Return) && (event.modifiers & Qt.ShiftModifier) && message.data.startsWith(">")) {
if(message.data.startsWith(">") && !message.data.endsWith("\n\n")) {
let newMessage1 = ""
if (message.data.endsWith("\n> ")) {
newMessage1 = message.data.substr(0, message.data.lastIndexOf("> ")) + "\n\n"
} else {
newMessage1 = message.data + "\n> ";
}
messageInputField.remove(0, messageInputField.cursorPosition);
insertInTextInput(0, StatusQUtils.Emoji.parse(newMessage1));
}
event.accepted = true
}
// handle backspace when entering an existing blockquote
if ((event.key === Qt.Key_Backspace || event.key === Qt.Key_Delete)) {
if(message.data.startsWith(">") && message.data.endsWith("\n\n")) {
const newMessage = message.data.substr(0, message.data.lastIndexOf("\n")) + "> ";
messageInputField.remove(0, messageInputField.cursorPosition);
insertInTextInput(0, StatusQUtils.Emoji.parse(newMessage));
event.accepted = true
}
}
if (event.matches(StandardKey.Copy) || event.matches(StandardKey.Cut)) {
if (messageInputField.selectedText !== "") {
d.copiedTextPlain = messageInputField.getText(
messageInputField.selectionStart, messageInputField.selectionEnd)
d.copiedTextFormatted = messageInputField.getFormattedText(
messageInputField.selectionStart, messageInputField.selectionEnd)
d.copyMentions(messageInputField.selectionStart, messageInputField.selectionEnd)
}
}
if (event.matches(StandardKey.Paste)) {
if (QClipboardProxy.hasImage) {
const clipboardImage = QClipboardProxy.imageBase64
showImageArea([clipboardImage])
event.accepted = true
} else {
messageInputField.remove(messageInputField.selectionStart, messageInputField.selectionEnd)
// cursor position must be stored in a helper property because setting readonly to true causes change
// of the cursor position to the end of the input
d.copyTextStart = messageInputField.cursorPosition
messageInputField.readOnly = true
const clipboardText = globalUtils.plainText(QClipboardProxy.text)
const copiedText = globalUtils.plainText(d.copiedTextPlain)
if (copiedText === clipboardText) {
d.internalPaste = true
} else {
d.copiedTextPlain = ""
d.copiedTextFormatted = ""
d.copiedMentionsPos = []
}
}
}
// ⌘⇧U
if (isUploadFilePressed(event)) {
imageBtn.clicked(null)
event.accepted = true
}
if (event.key === Qt.Key_Down && emojiSuggestions.visible) {
event.accepted = true
return emojiSuggestions.listView.incrementCurrentIndex()
}
if (event.key === Qt.Key_Up && emojiSuggestions.visible) {
event.accepted = true
return emojiSuggestions.listView.decrementCurrentIndex()
}
isColonPressed = (event.key === Qt.Key_Colon) && (event.modifiers & Qt.ShiftModifier);
if (suggestionsBox.visible) {
let aliasName = suggestionsBox.formattedPlainTextFilter;
let lastCursorPosition = suggestionsBox.suggestionFilter.cursorPosition;
let lastAtPosition = suggestionsBox.suggestionFilter.lastAtPosition;
let suggestionItem = suggestionsBox.suggestionsModel.get(suggestionsBox.listView.currentIndex);
if (aliasName.toLowerCase() === suggestionItem.name.toLowerCase()
&& (event.key !== Qt.Key_Backspace) && (event.key !== Qt.Key_Delete)) {
insertMention(aliasName, suggestionItem.publicKey, lastAtPosition, lastCursorPosition);
} else if (event.key === Qt.Key_Space) {
var plainTextToReplace = messageInputField.getText(lastAtPosition, lastCursorPosition);
messageInputField.remove(lastAtPosition, lastCursorPosition);
messageInputField.insert(lastAtPosition, plainTextToReplace);
suggestionsBox.hide();
}
}
}
function getLineStartPosition(selectionStart) {
const text = getPlainText()
const lastNewLinePos = text.lastIndexOf("\n\n", messageInputField.selectionStart)
return lastNewLinePos === -1 ? 0 : lastNewLinePos + 2
}
function prefixSelectedLine(prefix) {
const selectedLinePosition = getLineStartPosition(messageInputField.selectionStart)
insertInTextInput(selectedLinePosition, prefix)
}
function unprefixSelectedLine(prefix) {
if( isSelectedLinePrefixedBy(messageInputField.selectionStart, prefix) ) {
const selectedLinePosition = getLineStartPosition(messageInputField.selectionStart)
messageInputField.remove(selectedLinePosition, selectedLinePosition + prefix.length)
}
}
function isSelectedLinePrefixedBy(selectionStart, prefix) {
const selectedLinePosition = getLineStartPosition(selectionStart)
const text = getPlainText()
const selectedLine = text.substring(selectedLinePosition)
return selectedLine.startsWith(prefix)
}
function wrapSelection(wrapWith) {
if (messageInputField.selectionStart - messageInputField.selectionEnd === 0) return
// calulate the new selection start and end positions
var newSelectionStart = messageInputField.selectionStart + wrapWith.length
var newSelectionEnd = messageInputField.selectionEnd - messageInputField.selectionStart + newSelectionStart
insertInTextInput(messageInputField.selectionStart, wrapWith);
insertInTextInput(messageInputField.selectionEnd, wrapWith);
messageInputField.select(newSelectionStart, newSelectionEnd)
}
function unwrapSelection(unwrapWith, selectedTextWithFormationChars) {
if (messageInputField.selectionStart - messageInputField.selectionEnd === 0) return
// calulate the new selection start and end positions
var newSelectionStart = messageInputField.selectionStart - unwrapWith.length
var newSelectionEnd = messageInputField.selectionEnd-messageInputField.selectionStart + newSelectionStart
selectedTextWithFormationChars = selectedTextWithFormationChars.trim()
// Check if the selectedTextWithFormationChars has formation chars and if so, calculate how many so we can adapt the start and end pos
const selectTextDiff = (selectedTextWithFormationChars.length - messageInputField.selectedText.length) / 2
// Remove the deselected option from the before and after the selected text
const prefixChars = messageInputField.getText((messageInputField.selectionStart - selectTextDiff), messageInputField.selectionStart)
const updatedPrefixChars = prefixChars.replace(unwrapWith, '')
const postfixChars = messageInputField.getText(messageInputField.selectionEnd, (messageInputField.selectionEnd + selectTextDiff))
const updatedPostfixChars = postfixChars.replace(unwrapWith, '')
// Create updated selected string with pre and post formatting characters
const updatedSelectedStringWithFormatChars = updatedPrefixChars + messageInputField.selectedText + updatedPostfixChars
messageInputField.remove(messageInputField.selectionStart - selectTextDiff, messageInputField.selectionEnd + selectTextDiff)
insertInTextInput(messageInputField.selectionStart, updatedSelectedStringWithFormatChars)
messageInputField.select(newSelectionStart, newSelectionEnd)
}
function getPlainText() {
const textWithoutMention = messageInputField.text.replace(/(@([a-z\.]+(\ ?[a-z]+\ ?[a-z]+)?))<\/span>/ig, "\[\[mention\]\]$1\[\[mention\]\]")
const deparsedEmoji = StatusQUtils.Emoji.deparse(textWithoutMention);
return globalUtils.plainText(deparsedEmoji)
}
function removeMentions(currentText) {
return currentText.replace(/\[\[mention\]\]/g, '')
}
function parseMarkdown(markdownText) {
const htmlText = markdownText
.replace(/\~\~([^*]+)\~\~/gim, '~~$1~~')
.replace(/\*\*([^*]+)\*\*/gim, ':asterisk::asterisk:$1:asterisk::asterisk:')
.replace(/\`([^*]+)\`/gim, '`$1
`')
.replace(/\*([^*]+)\*/gim, ':asterisk:$1:asterisk:')
return htmlText.replace(/\:asterisk\:/gim, "*")
}
function getFormattedText(start, end) {
start = start || 0
end = end || messageInputField.length
const oldFormattedText = messageInputField.getFormattedText(start, end)
const found = oldFormattedText.match(/([\w\W\s]*)/m);
return found[1]
}
function getTextWithPublicKeys() {
let result = messageInputField.text
if (mentionsPos.length > 0) {
for (var k = 0; k < mentionsPos.length; k++) {
let leftIndex = result.indexOf(mentionsPos[k].name)
let rightIndex = leftIndex + mentionsPos[k].name.length
result = result.substring(0, leftIndex)
+ mentionsPos[k].pubKey
+ result.substring(rightIndex, result.length)
}
}
return result
}
function setFormatInInput(formationFunction, startTag, endTag, formationChar, numFormationChars) {
const inputText = getFormattedText()
const plainInputText = messageInputField.getText(0, messageInputField.length)
let lengthDifference
try {
const result = formationFunction(inputText)
if (!result) {
return
}
const parsed = JSON.parse(result)
let substring
let nbEmojis
parsed.forEach(function (match) {
match[1] += 1
const truncatedInputText = inputText.substring(0, match[1] + numFormationChars)
const truncatedPlainText = plainInputText.substring(0, messageInputField.cursorPosition)
const lengthDifference = truncatedInputText.length - truncatedPlainText.length
nbEmojis = StatusQUtils.Emoji.nbEmojis(truncatedInputText)
match[1] += (nbEmojis * -2)
match[0] += (nbEmojis * -2)
substring = inputText.substring(match[0], match[1])
if (plainInputText.charAt(match[0] - 1) !== formationChar) {
match[0] -= lengthDifference
match[1] -= lengthDifference
} else {
match[1] -= lengthDifference
}
messageInputField.remove(match[0], match[1])
insertInTextInput(match[0], `${startTag}${substring}${endTag}`)
})
} catch (e) {
//
}
}
function onRelease(event) {
if (event.key === Qt.Key_Backspace && textFormatMenu.opened) {
textFormatMenu.close()
}
// the text doesn't get registered to the textarea fast enough
// we can only get it in the `released` event
let eventText = event.text
if(event.key === Qt.Key_Space) {
eventText = " "
}
if(d.rightOfMentionIndex !== -1) {
messageInputField.insert(mentionsPos[d.rightOfMentionIndex].rightIndex, eventText)
d.rightOfMentionIndex = -1
}
if(d.leftOfMentionIndex !== -1) {
messageInputField.insert(mentionsPos[d.leftOfMentionIndex].leftIndex, eventText)
d.leftOfMentionIndex = -1
}
messageInputField.readOnly = false
if (d.internalPaste) {
if (d.copiedTextPlain.includes("@")) {
d.copiedTextFormatted = d.copiedTextFormatted.replace(/span style="/g, "span style=\" text-decoration:none;")
let lastFoundIndex = -1
for (let j = 0; j < d.copiedMentionsPos.length; j++) {
const name = d.copiedMentionsPos[j].name
const indexOfName = d.copiedTextPlain.indexOf(name, lastFoundIndex)
lastFoundIndex += name.length
if (indexOfName === d.copiedMentionsPos[j].leftIndex + 1) {
const mention = {
name: name,
pubKey: d.copiedMentionsPos[j].pubKey,
leftIndex: (d.copiedMentionsPos[j].leftIndex + d.copyTextStart - 1),
rightIndex: (d.copiedMentionsPos[j].leftIndex + d.copyTextStart + name.length)
}
mentionsPos.push(mention)
d.sortMentions()
}
}
}
const prevLength = messageInputField.length
insertInTextInput(d.copyTextStart, d.copiedTextFormatted)
messageInputField.cursorPosition = d.copyTextStart + messageInputField.length - prevLength
d.internalPaste = false
} else if (event.matches(StandardKey.Paste)) {
insertInTextInput(d.copyTextStart, QClipboardProxy.text)
messageInputField.cursorPosition = d.copyTextStart + QClipboardProxy.text.length
}
if (event.key !== Qt.Key_Escape) {
emojiEvent = emojiHandler(event)
if (!emojiEvent) {
emojiSuggestions.close()
}
}
}
// since emoji length is not 1 we need to match that position that TextArea returns
// to the actual position in the string.
function extrapolateCursorPosition() {
// we need only the message part to be html
const text = getPlainText()
const completelyPlainText = removeMentions(text)
const plainText = StatusQUtils.Emoji.parse(text);
var bracketEvent = false;
var almostMention = false;
var mentionEvent = false;
var length = 0;
// This loop calculates the cursor position inside the plain text which contains the image tags () and the mention tags ([[mention]])
const cursorPos = messageInputField.cursorPosition
let character = ""
for (var i = 0; i < plainText.length; i++) {
if (length >= cursorPos) break;
character = plainText.charAt(i)
if (!bracketEvent && character !== '<' && !mentionEvent && character !== '[') {
length++;
} else if (!bracketEvent && character === '<') {
bracketEvent = true;
} else if (bracketEvent && character === '>') {
bracketEvent = false;
length++;
} else if (!mentionEvent && almostMention && plainText.charAt(i) === '[') {
almostMention = false
mentionEvent = true
} else if (!mentionEvent && !almostMention && plainText.charAt(i) === '[') {
almostMention = true
} else if (!mentionEvent && almostMention && plainText.charAt(i) !== '[') {
almostMention = false
} else if (mentionEvent && !almostMention && plainText.charAt(i) === ']') {
almostMention = true
} else if (mentionEvent && almostMention && plainText.charAt(i) === ']') {
almostMention = false
mentionEvent = false
}
}
let textBeforeCursor = StatusQUtils.Emoji.deparseFromParse(plainText.substr(0, i));
return {
cursor: countEmojiLengths(plainText.substr(0, i)) + messageInputField.cursorPosition + text.length - completelyPlainText.length,
data: textBeforeCursor,
};
}
function emojiHandler(event) {
let message = extrapolateCursorPosition();
pollEmojiEvent(message);
// state machine to handle different forms of the emoji event state
if (!emojiEvent && isColonPressed) {
return (message.data.length <= 1 || Utils.isSpace(message.data.charAt(message.cursor - 1))) ? true : false;
} else if (emojiEvent && isColonPressed) {
const index = message.data.lastIndexOf(':', message.cursor - 2);
if (index >= 0 && message.cursor > 0) {
const shortname = message.data.substr(index, message.cursor);
const codePoint = StatusQUtils.Emoji.getEmojiUnicode(shortname);
if (codePoint !== undefined) {
replaceWithEmoji(message, shortname, codePoint);
}
return false;
}
return true;
} else if (emojiEvent && isKeyValid(event.key) && !isColonPressed) {
// popup
const index2 = message.data.lastIndexOf(':', message.cursor - 1);
if (index2 >= 0 && message.cursor > 0) {
const emojiPart = message.data.substr(index2, message.cursor);
if (emojiPart.length > 2) {
const emojis = StatusQUtils.Emoji.emojiJSON.emoji_json.filter(function (emoji) {
return emoji.name.includes(emojiPart) ||
emoji.shortname.includes(emojiPart) ||
emoji.aliases.some(a => a.includes(emojiPart))
})
emojiSuggestions.openPopup(emojis, emojiPart)
}
return true;
}
} else if (emojiEvent && !isKeyValid(event.key) && !isColonPressed) {
return false;
}
return false;
}
function countEmojiLengths(value) {
const match = StatusQUtils.Emoji.getEmojis(value);
var length = 0;
if (match && match.length > 0) {
for (var i = 0; i < match.length; i++) {
length += StatusQUtils.Emoji.deparseFromParse(match[i]).length;
}
length = length - match.length;
}
return length;
}
function replaceWithEmoji(message, shortname, codePoint) {
const encodedCodePoint = StatusQUtils.Emoji.getEmojiCodepoint(codePoint)
messageInputField.remove(messageInputField.cursorPosition - shortname.length, messageInputField.cursorPosition);
insertInTextInput(messageInputField.cursorPosition, StatusQUtils.Emoji.parse(encodedCodePoint) + " ");
emojiSuggestions.close()
emojiEvent = false
}
// check if user has placed cursor near valid emoji colon token
function pollEmojiEvent(message) {
const index = message.data.lastIndexOf(':', message.cursor);
if (index >= 0) {
emojiEvent = validSubstr(message.data.substr(index, message.cursor - index));
}
}
function validSubstr(substr) {
for(var i = 0; i < substr.length; i++) {
var c = substr.charAt(i);
if (Utils.isSpace(c) || Utils.isPunct(c))
return false;
}
return true;
}
function isKeyValid(key) {
if (key === Qt.Key_Space || key === Qt.Key_Tab ||
(key >= Qt.Key_Exclam && key <= Qt.Key_Slash) ||
(key >= Qt.Key_Semicolon && key <= Qt.Key_Question) ||
(key >= Qt.Key_BracketLeft && key <= Qt.Key_hyphen))
return false;
return true;
}
function hideExtendedArea() {
isImage = false;
isReply = false;
control.fileUrlsAndSources = []
imageArea.imageSource = [];
replyArea.userName = ""
replyArea.message = ""
for (let i=0; i x.toString())
let validImages = Utils.deduplicate(existing.concat(imagePaths))
for (let i=0; i validator.validImages.includes(validImage))
}
return validImages
}
function showImageArea(imagePathsOrData) {
isImage = true;
isReply = false;
imageArea.imageSource = imagePathsOrData
control.fileUrlsAndSources = imageArea.imageSource
}
function showReplyArea(messageId, userName, message, contentType, image, sticker) {
isReply = true
replyArea.userName = userName
replyArea.message = message
replyArea.contentType = contentType
replyArea.image = image
replyArea.stickerData = sticker
replyArea.messageId = messageId
messageInputField.forceActiveFocus();
}
function forceInputActiveFocus() {
messageInputField.forceActiveFocus();
}
Connections {
enabled: control.isActiveChannel
target: Global.dragArea
ignoreUnknownSignals: true
onDroppedOnValidScreen: (drop) => {
let validImages = validateImages(drop.urls)
if (validImages.length > 0) {
showImageArea(validImages)
drop.acceptProposedAction()
}
}
}
// This is used by Squish tests to not have to access the file dialog
function selectImageString(filePath) {
let validImages = validateImages([filePath])
if (validImages.length > 0) {
control.showImageArea(validImages)
}
messageInputField.forceActiveFocus();
}
FileDialog {
id: imageDialog
title: qsTr("Please choose an image")
folder: shortcuts.pictures
selectMultiple: true
nameFilters: [
qsTr("Image files (%1)").arg(Constants.acceptedDragNDropImageExtensions.map(img => "*" + img).join(" "))
]
onAccepted: {
imageBtn.highlighted = false
let validImages = validateImages(imageDialog.fileUrlsAndSources)
if (validImages.length > 0) {
control.showImageArea(validImages)
}
messageInputField.forceActiveFocus();
}
onRejected: {
imageBtn.highlighted = false
}
}
MessageDialog {
id: messageTooLongDialog
title: qsTr("Your message is too long.")
icon: StandardIcon.Critical
text: qsTr("Please make your message shorter. We have set the limit to 2000 characters to be courteous of others.")
standardButtons: StandardButton.Ok
}
StatusEmojiSuggestionPopup {
id: emojiSuggestions
messageInput: messageInput
onClicked: function (index) {
if (index === undefined) {
index = emojiSuggestions.listView.currentIndex
}
const unicode = emojiSuggestions.modelList[index].unicode_alternates || emojiSuggestions.modelList[index].unicode
replaceWithEmoji(extrapolateCursorPosition(), emojiSuggestions.shortname, unicode);
}
}
SuggestionBoxPanel {
id: suggestionsBox
objectName: "suggestionsBox"
model: control.usersStore ? control.usersStore.usersModel : []
x : messageInput.x
y: -height - Style.current.smallPadding
width: messageInput.width
filter: messageInputField.text
cursorPosition: messageInputField.cursorPosition
property: ["name", "nickname", "ensName", "alias"]
inputField: messageInputField
onItemSelected: function (item, lastAtPosition, lastCursorPosition) {
messageInputField.forceActiveFocus();
let name = item.name.replace("@", "")
insertMention(name, item.publicKey, lastAtPosition, lastCursorPosition)
suggestionsBox.suggestionsModel.clear()
}
onVisibleChanged: {
if (!visible) {
messageInputField.forceActiveFocus();
}
}
}
ChatCommandsPopup {
id: chatCommandsPopup
x: 8
y: -height
onSendTransactionCommandButtonClicked: {
control.sendTransactionCommandButtonClicked()
chatCommandsPopup.close()
}
onReceiveTransactionCommandButtonClicked: {
control.receiveTransactionCommandButtonClicked()
chatCommandsPopup.close()
}
onClosed: {
chatCommandsBtn.highlighted = false
}
onOpened: {
chatCommandsBtn.highlighted = true
}
}
StatusGifPopup {
id: gifPopup
width: 360
height: 440
x: control.width - width - Style.current.halfPadding
y: -height
gifSelected: function (event, url) {
messageInputField.text += "\n" + url
control.sendMessage(event)
gifBtn.highlighted = false
messageInputField.forceActiveFocus()
if (control.closeGifPopupAfterSelection)
gifPopup.close()
}
onClosed: {
gifBtn.highlighted = false
}
}
RowLayout {
id: layout
anchors.fill: parent
spacing: 4
StatusQ.StatusFlatRoundButton {
id: chatCommandsBtn
Layout.preferredWidth: 32
Layout.preferredHeight: 32
Layout.alignment: Qt.AlignBottom
Layout.bottomMargin: 4
icon.name: "chat-commands"
type: StatusQ.StatusFlatRoundButton.Type.Tertiary
visible: RootStore.isWalletEnabled && !isEdit && control.chatType === Constants.chatType.oneToOne
enabled: !control.isContactBlocked
onClicked: {
chatCommandsPopup.opened ?
chatCommandsPopup.close() :
chatCommandsPopup.open()
}
}
StatusQ.StatusFlatRoundButton {
id: imageBtn
Layout.preferredWidth: 32
Layout.preferredHeight: 32
Layout.alignment: Qt.AlignBottom
Layout.bottomMargin: 4
icon.name: "image"
type: StatusQ.StatusFlatRoundButton.Type.Tertiary
visible: !isEdit && control.chatType !== Constants.chatType.publicChat
enabled: !control.isContactBlocked
onClicked: {
highlighted = true
imageDialog.open()
}
}
Rectangle {
id: messageInput
readonly property int defaultInputFieldHeight: 40
Layout.fillWidth: true
implicitHeight: inputLayout.implicitHeight + inputLayout.anchors.topMargin + inputLayout.anchors.bottomMargin
implicitWidth: inputLayout.implicitWidth + inputLayout.anchors.leftMargin + inputLayout.anchors.rightMargin
enabled: !control.isContactBlocked
color: isEdit ? Theme.palette.statusChatInput.secondaryBackgroundColor : Style.current.inputBackground
radius: 20
StatusTextFormatMenu {
id: textFormatMenu
StatusChatInputTextFormationAction {
wrapper: "**"
icon.name: "bold"
text: qsTr("Bold")
selectedTextWithFormationChars: RootStore.getSelectedTextWithFormationChars(messageInputField)
onActionTriggered: checked ?
unwrapSelection(wrapper, RootStore.getSelectedTextWithFormationChars(messageInputField)) :
wrapSelection(wrapper)
}
StatusChatInputTextFormationAction {
wrapper: "*"
icon.name: "italic"
text: qsTr("Italic")
selectedTextWithFormationChars: RootStore.getSelectedTextWithFormationChars(messageInputField)
checked: (surroundedBy("*") && !surroundedBy("**")) || surroundedBy("***")
onActionTriggered: checked ?
unwrapSelection(wrapper, RootStore.getSelectedTextWithFormationChars(messageInputField)) :
wrapSelection(wrapper)
}
StatusChatInputTextFormationAction {
wrapper: "~~"
icon.name: "strikethrough"
text: qsTr("Strikethrough")
selectedTextWithFormationChars: RootStore.getSelectedTextWithFormationChars(messageInputField)
onActionTriggered: checked ?
unwrapSelection(wrapper, RootStore.getSelectedTextWithFormationChars(messageInputField)) :
wrapSelection(wrapper)
}
StatusChatInputTextFormationAction {
wrapper: "`"
icon.name: "code"
text: qsTr("Code")
selectedTextWithFormationChars: RootStore.getSelectedTextWithFormationChars(messageInputField)
onActionTriggered: checked ?
unwrapSelection(wrapper, RootStore.getSelectedTextWithFormationChars(messageInputField)) :
wrapSelection(wrapper)
}
StatusChatInputTextFormationAction {
wrapper: "> "
icon.name: "quote"
text: qsTr("Quote")
checked: messageInputField.selectedText && isSelectedLinePrefixedBy(messageInputField.selectionStart, wrapper)
onActionTriggered: checked
? unprefixSelectedLine(wrapper)
: prefixSelectedLine(wrapper)
}
onClosed: {
messageInputField.deselect();
}
}
ColumnLayout {
id: validators
anchors.bottom: control.imageErrorMessageLocation === StatusChatInput.ImageErrorMessageLocation.Top ? parent.top : undefined
anchors.bottomMargin: control.imageErrorMessageLocation === StatusChatInput.ImageErrorMessageLocation.Top ? -4 : undefined
anchors.top: control.imageErrorMessageLocation === StatusChatInput.ImageErrorMessageLocation.Bottom ? parent.bottom : undefined
anchors.topMargin: control.imageErrorMessageLocation === StatusChatInput.ImageErrorMessageLocation.Bottom ? (isImage ? -4 : 4) : undefined
anchors.horizontalCenter: parent.horizontalCenter
width: parent.width
z: 1
StatusChatImageExtensionValidator {
Layout.alignment: Qt.AlignHCenter
}
StatusChatImageSizeValidator {
Layout.alignment: Qt.AlignHCenter
}
StatusChatImageQtyValidator {
Layout.alignment: Qt.AlignHCenter
}
}
Rectangle {
// Bottom right corner has different radius
color: parent.color
anchors.bottom: parent.bottom
anchors.right: parent.right
height: parent.height / 2
width: 32
radius: Style.current.radius
}
ColumnLayout {
id: inputLayout
anchors.fill: parent
spacing: 4
StatusChatInputReplyArea {
id: replyArea
visible: isReply
Layout.fillWidth: true
Layout.margins: 2
onCloseButtonClicked: {
isReply = false
}
}
StatusChatInputImageArea {
id: imageArea
Layout.fillWidth: true
Layout.leftMargin: Style.current.halfPadding
Layout.rightMargin: Style.current.halfPadding
visible: isImage
onImageClicked: Global.openImagePopup(chatImage, messageContextMenu)
onImageRemoved: {
if (control.fileUrlsAndSources.length > index && control.fileUrlsAndSources[index]) {
control.fileUrlsAndSources.splice(index, 1)
}
isImage = control.fileUrlsAndSources.length > 0
validateImages(control.fileUrlsAndSources)
}
}
RowLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: Style.current.radius
StatusScrollView {
id: inputScrollView
Layout.fillWidth: true
Layout.fillHeight: true
Layout.leftMargin: 12
Layout.rightMargin: 12
Layout.maximumHeight: 112
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
padding: 0
TextArea {
id: messageInputField
objectName: "messageInputField"
property var lastClick: 0
property int cursorWhenPressed: 0
width: inputScrollView.availableWidth
textFormat: Text.RichText
font.pixelSize: 15
font.family: Style.current.baseFont.name
wrapMode: TextArea.Wrap
placeholderText: control.chatInputPlaceholder
placeholderTextColor: Style.current.secondaryText
selectByMouse: true
color: isEdit ? Theme.palette.directColor1 : Style.current.textColor
topPadding: 9
bottomPadding: 9
leftPadding: 0
padding: 0
Keys.onPressed: {
if (mentionsPos.length > 0) {
for (var i = 0; i < mentionsPos.length; i++) {
if ((messageInputField.cursorPosition === (mentionsPos[i].leftIndex))
&& (event.key === Qt.Key_Delete)) {
messageInputField.remove(mentionsPos[i].rightIndex, mentionsPos[i].leftIndex)
mentionsPos.pop(i)
d.sortMentions()
}
}
}
keyEvent = event;
onKeyPress(event)
cursorWhenPressed = cursorPosition;
}
Keys.onReleased: onRelease(event) // gives much more up to date cursorPosition
Keys.onShortcutOverride: event.accepted = isUploadFilePressed(event)
selectionColor: Style.current.primarySelectionColor
persistentSelection: true
property var keyEvent
Component.onDestruction: {
// NOTE: Without losing focus the app crashes on apply/cancel message editing.
control.forceActiveFocus();
}
onCursorPositionChanged: {
if (mentionsPos.length > 0) {
for (var i = 0; i < mentionsPos.length; i++) {
if ((messageInputField.cursorPosition === (mentionsPos[i].leftIndex + 1)) && (keyEvent.key === Qt.Key_Right)) {
messageInputField.cursorPosition = mentionsPos[i].rightIndex;
} else if (messageInputField.cursorPosition === (mentionsPos[i].rightIndex - 1)) {
if (keyEvent.key === Qt.Key_Left) {
messageInputField.cursorPosition = mentionsPos[i].leftIndex;
} else if ((keyEvent.key === Qt.Key_Backspace) || (keyEvent.key === Qt.Key_Delete)) {
messageInputField.remove(mentionsPos[i].rightIndex, mentionsPos[i].leftIndex);
mentionsPos.pop(i);
d.sortMentions()
}
} else if (((messageInputField.cursorPosition > mentionsPos[i].leftIndex) &&
(messageInputField.cursorPosition < mentionsPos[i].rightIndex)) &&
((keyEvent.key === Qt.Key_Left) && ((keyEvent.modifiers & Qt.AltModifier) ||
(keyEvent.modifiers & Qt.ControlModifier)))) {
messageInputField.cursorPosition = mentionsPos[i].leftIndex;
} else if ((keyEvent.key === Qt.Key_Up) || (keyEvent.key === Qt.Key_Down)) {
if (messageInputField.cursorPosition >= mentionsPos[i].leftIndex &&
messageInputField.cursorPosition <= (((mentionsPos[i].leftIndex + mentionsPos[i].rightIndex)/2))) {
messageInputField.cursorPosition = mentionsPos[i].leftIndex;
} else if (messageInputField.cursorPosition <= mentionsPos[i].rightIndex &&
messageInputField.cursorPosition > (((mentionsPos[i].leftIndex + mentionsPos[i].rightIndex)/2))) {
messageInputField.cursorPosition = mentionsPos[i].rightIndex;
}
}
}
}
inputScrollView.ensureVisible(cursorRectangle)
}
onTextChanged: {
if (length <= control.messageLimit) {
var symbols = ":='xX><0O;*dB8-D#%\\";
if ((length > 1) && (symbols.indexOf(getText((cursorPosition - 2), (cursorPosition - 1))) !== -1)
&& (!getText((cursorPosition - 7), cursorPosition).includes("http"))) {
const emojis = StatusQUtils.Emoji.emojiJSON.emoji_json.filter(function (emoji) {
if (emoji.aliases_ascii.includes(getText((cursorPosition - 2), cursorPosition)) ||
emoji.aliases_ascii.includes(getText((cursorPosition - 3), cursorPosition))) {
var has2Chars = emoji.aliases_ascii.includes(getText((cursorPosition - 2), cursorPosition));
replaceWithEmoji("", getText(cursorPosition - (has2Chars ? 2 : 3), cursorPosition), emoji.unicode);
}
})
}
if (length === 0) {
mentionsPos = [];
}
} else {
var removeFrom = (cursorPosition < messageLimit) ? cursorWhenPressed : messageLimit;
remove(removeFrom, cursorPosition);
}
d.updateMentionsPositions()
messageLengthLimit.remainingChars = (messageLimit - length);
}
onReleased: function (event) {
const now = Date.now()
if (messageInputField.selectedText.trim() !== "") {
// If it's a double click, just check the mouse position
// If it's a mouse select, use the start and end position average)
let x = now < messageInputField.lastClick + 500 ? x = event.x :
(messageInputField.cursorRectangle.x + event.x) / 2
x -= textFormatMenu.width / 2
textFormatMenu.popup(x, messageInputField.y - textFormatMenu.height - 5)
messageInputField.forceActiveFocus();
}
lastClick = now
}
cursorDelegate: Rectangle {
color: Theme.palette.primaryColor1
implicitWidth: 2
implicitHeight: 22
radius: 1
visible: messageInputField.cursorVisible
SequentialAnimation on visible {
loops: Animation.Infinite
running: messageInputField.cursorVisible
PropertyAnimation { to: false; duration: 600; }
PropertyAnimation { to: true; duration: 600; }
}
}
StatusSyntaxHighlighter {
quickTextDocument: messageInputField.textDocument
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.NoButton
enabled: parent.hoveredLink
cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.IBeamCursor
}
}
Shortcut {
enabled: messageInputField.activeFocus
sequence: StandardKey.Bold
onActivated: wrapSelection("**")
}
Shortcut {
enabled: messageInputField.activeFocus
sequence: StandardKey.Italic
onActivated: wrapSelection("*")
}
Shortcut {
enabled: messageInputField.activeFocus
sequence: "Ctrl+Shift+Alt+C"
onActivated: wrapSelection("```")
}
Shortcut {
enabled: messageInputField.activeFocus
sequence: "Ctrl+Shift+C"
onActivated: wrapSelection("`")
}
Shortcut {
enabled: messageInputField.activeFocus
sequence: "Ctrl+Alt+-"
onActivated: wrapSelection("~~")
}
Shortcut {
enabled: messageInputField.activeFocus
sequence: "Ctrl+Shift+X"
onActivated: wrapSelection("~~")
}
Shortcut {
enabled: messageInputField.activeFocus
sequence: "Ctrl+Meta+Space"
onActivated: emojiBtn.clicked(null)
}
}
Column {
Layout.alignment: Qt.AlignBottom
Layout.bottomMargin: 3
StyledText {
id: messageLengthLimit
property int remainingChars: -1
leftPadding: Style.current.halfPadding
rightPadding: Style.current.halfPadding
visible: ((messageInputField.length >= control.messageLimitVisible) && (messageInputField.length <= control.messageLimit))
color: (remainingChars <= messageLimitVisible) ? Style.current.danger : Style.current.textColor
text: visible ? remainingChars.toString() : ""
}
Row {
id: actions
spacing: 2
StatusQ.StatusFlatRoundButton {
id: emojiBtn
objectName: "statusChatInputEmojiButton"
implicitHeight: 32
implicitWidth: 32
icon.name: "emojis"
icon.color: (hovered || highlighted) ? Theme.palette.primaryColor1
: Theme.palette.baseColor1
type: StatusQ.StatusFlatRoundButton.Type.Tertiary
color: "transparent"
onClicked: {
control.emojiPopupOpened = true
togglePopup(emojiPopup, emojiBtn)
}
}
StatusQ.StatusFlatRoundButton {
id: gifBtn
objectName: "gifPopupButton"
implicitHeight: 32
implicitWidth: 32
visible: !isEdit && RootStore.isGifWidgetEnabled
icon.name: "gif"
icon.color: (hovered || highlighted) ? Theme.palette.primaryColor1
: Theme.palette.baseColor1
type: StatusQ.StatusFlatRoundButton.Type.Tertiary
color: "transparent"
onClicked: togglePopup(gifPopup, gifBtn)
}
StatusQ.StatusFlatRoundButton {
id: stickersBtn
objectName: "statusChatInputStickersButton"
implicitHeight: 32
implicitWidth: 32
width: visible ? 32 : 0
icon.name: "stickers"
icon.color: (hovered || highlighted) ? Theme.palette.primaryColor1
: Theme.palette.baseColor1
type: StatusQ.StatusFlatRoundButton.Type.Tertiary
visible: !isEdit && emojiBtn.visible
color: "transparent"
onClicked: {
control.stickersPopupOpened = true
togglePopup(control.stickersPopup, stickersBtn)
}
}
}
}
}
}
}
StatusQ.StatusButton {
id: unblockBtn
Layout.alignment: Qt.AlignBottom
Layout.bottomMargin: 4
visible: control.isContactBlocked
text: qsTr("Unblock")
type: StatusQ.StatusBaseButton.Type.Danger
onClicked: function (event) {
control.unblockChat()
}
}
}
}