import QtQuick 2.13
import QtQuick.Controls 2.13
import QtQuick.Layouts 1.13
import QtQuick.Dialogs 1.3
import utils 1.0
import shared 1.0
import shared.controls.chat 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 0.1
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
objectName: "statusChatInput"
signal stickerSelected(string hashId, string packId, string url)
signal sendMessage(var event)
signal keyUpPress()
signal linkPreviewRemoved(string link)
signal linkPreviewReloaded(string link)
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 closeGifPopupAfterSelection: true
property bool emojiEvent: false
property bool isColonPressed: false
property bool isReply: false
readonly property string replyMessageId: replyArea.messageId
property bool isImage: false
property bool isEdit: false
readonly property int messageLimit: 2000 // actual message limit, we don't allow sending more than that
readonly property int messageLimitSoft: 200 // we start showing a char counter when this no. of chars left in the message
readonly property int messageLimitHard: 20000 // still cut-off attempts to paste beyond this limit, for app usability reasons
property int chatType
property string chatInputPlaceholder: qsTr("Message")
property alias textInput: messageInputField
property var fileUrlsAndSources: []
property var linkPreviewModel: null
property var imageErrorMessageLocation: StatusChatInput.ImageErrorMessageLocation.Top // TODO: Remove this property?
property alias suggestions: suggestionsBox
enum ImageErrorMessageLocation {
Top,
Bottom
}
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
readonly property string emojiReplacementSymbols: ":='xX><0O;*dB8-D#%\\"
//mentions helper properties
property string copiedTextPlain: ""
property string copiedTextFormatted: ""
property var copiedMentionsPos: []
property int copyTextStart: 0
property int leftOfMentionIndex: -1
property int rightOfMentionIndex: -1
readonly property int nbEmojisInClipboard: StatusQUtils.Emoji.nbEmojis(QClipboardProxy.html)
property bool emojiPopupOpened: false
property bool stickersPopupOpened: false
// common popups are emoji, jif and stickers
// Put controlWidth as argument with default value for binding
function getCommonPopupRelativePosition(popup, popupParent, controlWidth = control.width) {
const popupWidth = emojiPopup ? emojiPopup.width : 0
const popupHeight = emojiPopup ? emojiPopup.height : 0
const controlX = controlWidth - popupWidth - Style.current.halfPadding
const controlY = -popupHeight
return popupParent.mapFromItem(control, controlX, controlY)
}
readonly property point emojiPopupPosition: getCommonPopupRelativePosition(emojiPopup, emojiBtn)
readonly property point stickersPopupPosition: getCommonPopupRelativePosition(stickersPopup, stickersBtn)
readonly property StateGroup emojiPopupTakeover: StateGroup {
states: State {
when: d.emojiPopupOpened
PropertyChanges {
target: emojiPopup
parent: emojiBtn
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutsideParent
x: d.emojiPopupPosition.x
y: d.emojiPopupPosition.y
}
}
}
readonly property StateGroup stickersPopupTakeover: StateGroup {
states: State {
when: d.stickersPopupOpened
PropertyChanges {
target: stickersPopup
parent: stickersBtn
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutsideParent
x: d.stickersPopupPosition.x
y: d.stickersPopupPosition.y
}
}
}
property Menu textFormatMenu: null
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
}
}
sortMentions()
}
function cleanMentionsPos() {
if(mentionsPos.length == 0) return
const unformattedText = messageInputField.getText(0, messageInputField.length)
mentionsPos = mentionsPos.filter(mention => unformattedText.charAt(mention.leftIndex) === "@")
}
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 = mentionsPos.filter(mention => mention.leftIndex !== lastAtPosition)
mentionsPos.push({name: aliasName, pubKey: publicKey, leftIndex: lastAtPosition, rightIndex: (lastAtPosition+aliasName.length + 1)});
d.sortMentions()
}
function removeMention(mention) {
const index = mentionsPos.indexOf(mention)
if(index >= 0) {
mentionsPos.splice(index, 1)
}
messageInputField.remove(mention.leftIndex, mention.rightIndex)
}
function getMentionAtPosition(position: int) {
return mentionsPos.find(mention => mention.leftIndex < position && mention.rightIndex > position)
}
}
function insertInTextInput(start, text) {
// Replace new lines with entities because `insert` gets rid of them
messageInputField.insert(start, text.replace(/\n/g, "
"));
}
Connections {
enabled: d.emojiPopupOpened
target: emojiPopup
function onEmojiSelected(text: string, atCursor: bool) {
insertInTextInput(atCursor ? messageInputField.cursorPosition : messageInputField.length, text)
emojiBtn.highlighted = false
messageInputField.forceActiveFocus();
}
function onClosed() {
d.emojiPopupOpened = false
}
}
Connections {
enabled: d.stickersPopupOpened
target: control.stickersPopup
function onStickerSelected(hashId: string, packId: string, url: string ) {
control.stickerSelected(hashId, packId, url)
control.hideExtendedArea();
messageInputField.forceActiveFocus();
}
function onClosed() {
d.stickersPopupOpened = false
}
}
property var mentionsPos: []
function isUploadFilePressed(event) {
return (event.key === Qt.Key_U) && (event.modifiers & Qt.ControlModifier) && imageBtn.visible && !imageBtn.highlighted
}
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) {
// get text without HTML formatting
const messageLength = messageInputField.getText(0, messageInputField.length).length;
if (event.modifiers === Qt.NoModifier && (event.key === Qt.Key_Enter || event.key === Qt.Key_Return)) {
if (checkTextInsert()) {
event.accepted = true;
return
}
if (messageLength <= messageLimit) {
checkForInlineEmojis(true);
control.sendMessage(event);
control.hideExtendedArea();
event.accepted = true;
return;
}
else {
// pop-up a warning message when trying to send a message over the limit
messageLengthLimitTooltip.open();
event.accepted = true;
return;
}
}
if (event.key === Qt.Key_Escape && control.isReply) {
control.isReply = false;
event.accepted = true;
return;
}
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 && (messageInputField.selectedText.length === 0)) {
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
}
if (event.key === Qt.Key_Delete || event.key === Qt.Key_Backspace) {
if(mentionsPos.length > 0) {
let anticipatedCursorPosition = messageInputField.cursorPosition
anticipatedCursorPosition += event.key === Qt.Key_Backspace ?
-1 : 1
const mention = d.getMentionAtPosition(anticipatedCursorPosition)
if(mention) {
d.removeMention(mention)
event.accepted = true
}
}
// handle backspace when entering an existing blockquote
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
validateImagesAndShowImageArea([clipboardImage])
event.accepted = true
} else if (QClipboardProxy.hasText) {
const clipboardText = Utils.plainText(QClipboardProxy.text)
// prevent repetitive & huge clipboard paste, where huge is total char count > than messageLimitHard
const selectionLength = messageInputField.selectionEnd - messageInputField.selectionStart;
if ((messageLength + clipboardText.length - selectionLength) > control.messageLimitHard)
{
messageLengthLimitTooltip.open();
event.accepted = true;
return;
}
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 copiedText = Utils.plainText(d.copiedTextPlain)
if (copiedText === clipboardText) {
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()
}
}
}
insertInTextInput(d.copyTextStart, d.copiedTextFormatted)
} else {
d.copiedTextPlain = ""
d.copiedTextFormatted = ""
d.copiedMentionsPos = []
messageInputField.insert(d.copyTextStart, ((d.nbEmojisInClipboard === 0) ?
("
$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 onRelease(event) {
if ((event.modifiers & Qt.ControlModifier) || (event.modifiers & Qt.MetaModifier)) // these are likely shortcuts with no meaningful text
return
if (event.key === Qt.Key_Backspace && (!!d.textFormatMenu && d.textFormatMenu.opened)) {
d.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) {
//make sure to add an extra space between mention and text
let mentionSeparator = event.key === Qt.Key_Space ? "" : " "
messageInputField.insert(mentionsPos[d.rightOfMentionIndex].rightIndex, mentionSeparator + eventText)
d.rightOfMentionIndex = -1
}
if(d.leftOfMentionIndex !== -1) {
messageInputField.insert(mentionsPos[d.leftOfMentionIndex].leftIndex, eventText)
d.leftOfMentionIndex = -1
}
if (event.key !== Qt.Key_Escape) {
emojiEvent = emojiHandler(event)
if (!emojiEvent) {
emojiSuggestions.close()
}
}
if (messageInputField.readOnly) {
messageInputField.readOnly = false;
messageInputField.cursorPosition = (d.copyTextStart + QClipboardProxy.text.length + d.nbEmojisInClipboard);
}
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 !== "" && aliasName.toLowerCase() === suggestionItem.name.toLowerCase()
&& (event.key !== Qt.Key_Backspace) && (event.key !== Qt.Key_Delete)) {
d.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();
}
}
}
// 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.deparse(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.deparse(match[i]).length;
}
length = length - match.length;
}
return length;
}
function checkForInlineEmojis(force = false) {
// trigger inline emoji replacements after space, or always (force==true) when sending the message
if (force || messageInputField.getText(messageInputField.cursorPosition, messageInputField.cursorPosition - 1) === " ") {
// figure out last word (between spaces), max length of 5
var lastWord = ""
const cursorPos = messageInputField.cursorPosition - (force ? 1 : 2) // just before the last non-space character
for (let i = cursorPos; i > cursorPos - 6; i--) { // go back until we found a space or start of line
const lastChar = messageInputField.getText(i, i+1)
if (i < 0 || lastChar === " ") { // reached start of line or a space
break
} else {
lastWord = lastChar + lastWord // collect the last word
}
}
// check if the word contains any of the trigger chars (emojiReplacementSymbols)
if (!!lastWord && Array.prototype.some.call(d.emojiReplacementSymbols, (trigger) => lastWord.includes(trigger))) {
// search the ASCII aliases for a possible match
const emojiFound = StatusQUtils.Emoji.emojiJSON.emoji_json.find(emoji => emoji.aliases_ascii.includes(lastWord))
if (emojiFound) {
replaceWithEmoji("", lastWord, emojiFound.unicode, force ? 0 : 1 /*offset*/);
}
}
}
}
function replaceWithEmoji(message, shortname, codePoint, offset = 0) {
const encodedCodePoint = StatusQUtils.Emoji.getEmojiCodepoint(codePoint)
messageInputField.remove(messageInputField.cursorPosition - shortname.length - offset, 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 resetImageArea() {
isImage = false;
control.fileUrlsAndSources = []
for (let i=0; i