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 linkPreviewReloaded(string link)
signal enableLinkPreview()
signal enableLinkPreviewForThisMessage()
signal disableLinkPreview()
signal dismissLinkPreviewSettings()
signal dismissLinkPreview(int index)
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 urlsList: null
property bool askToEnableLinkPreview: false
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
}
function setText(text) {
textInput.clear()
textInput.append(text)
}
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 (