mirror of
https://github.com/status-im/status-desktop.git
synced 2025-01-10 06:16:32 +00:00
42e3444731
There was a UI bug in the StatusChatInput where, only in the timeline view, the scrollview wrapping the textarea would have a weird behaviour in which it would render the visible text(placeholder) *lower* than how it's actually positioned. Once the textarea is clicked, it would re-adjust with a smooth scroll. Obviously this is undesired behaviour as the text should always render in the correct position. This commit finally fixes this weird behaviour by moving the dynamic height calculation to the wrapping Rectangle and changing the textarea's top- and bottom paddings dynamically. Fixes #2018
1012 lines
38 KiB
QML
1012 lines
38 KiB
QML
import QtQuick 2.13
|
|
import QtQuick.Controls 2.13
|
|
import QtGraphicalEffects 1.13
|
|
import QtQuick.Layouts 1.13
|
|
import QtMultimedia 5.13
|
|
import QtQuick.Dialogs 1.3
|
|
import "../../imports"
|
|
import "../../shared"
|
|
import "../../app/AppLayouts/Chat/ChatColumn/samples"
|
|
import "../../app/AppLayouts/Chat/ChatColumn"
|
|
|
|
import "./emojiList.js" as EmojiJSON
|
|
|
|
Rectangle {
|
|
id: control
|
|
signal sendTransactionCommandButtonClicked()
|
|
signal receiveTransactionCommandButtonClicked()
|
|
signal stickerSelected(string hashId, string packId)
|
|
signal sendMessage(var event)
|
|
|
|
property bool emojiEvent: false;
|
|
property bool paste: false;
|
|
property bool isColonPressed: false;
|
|
property bool isReply: false
|
|
property bool isImage: false
|
|
|
|
property var recentStickers
|
|
property var stickerPackList
|
|
|
|
property int extraHeightFactor: calculateExtraHeightFactor()
|
|
property int messageLimit: control.isStatusUpdateInput ? 300 : 2000
|
|
property int messageLimitVisible: control.isStatusUpdateInput ? 50 : 200
|
|
|
|
property int chatType
|
|
|
|
//% "Type a message."
|
|
property string chatInputPlaceholder: qsTrId("type-a-message-")
|
|
|
|
property alias textInput: messageInputField
|
|
property bool isStatusUpdateInput: chatType === Constants.chatTypeStatusUpdate
|
|
|
|
property var fileUrls: []
|
|
|
|
property alias suggestionsList: suggestions
|
|
property alias suggestions: suggestionsBox
|
|
|
|
property var imageErrorMessageLocation: StatusChatInput.ImageErrorMessageLocation.Top
|
|
|
|
enum ImageErrorMessageLocation {
|
|
Top,
|
|
Bottom
|
|
}
|
|
|
|
height: {
|
|
if (extendedArea.visible) {
|
|
return messageInput.height + extendedArea.height + (control.isStatusUpdateInput ? 0 : Style.current.bigPadding)
|
|
}
|
|
if (messageInput.height > messageInput.defaultInputFieldHeight) {
|
|
if (messageInput.height >= messageInput.maxInputFieldHeight) {
|
|
return messageInput.maxInputFieldHeight + (control.isStatusUpdateInput ? 0 : Style.current.bigPadding)
|
|
}
|
|
return messageInput.height + (control.isStatusUpdateInput ? 0 : Style.current.bigPadding)
|
|
}
|
|
return control.isStatusUpdateInput ? 56 : 64
|
|
}
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
|
|
color: Style.current.background
|
|
|
|
function calculateExtraHeightFactor() {
|
|
const factor = (messageInputField.length / 500) + 1;
|
|
return (factor > 5) ? 5 : factor;
|
|
}
|
|
|
|
function insertInTextInput(start, text) {
|
|
// Repace new lines with entities because `insert` gets rid of them
|
|
messageInputField.insert(start, text.replace(/\n/g, "<br/>"));
|
|
}
|
|
|
|
property var interpretMessage: function (msg) {
|
|
if (msg.startsWith("/shrug")) {
|
|
return msg.replace("/shrug", "") + " ¯\\\\\\_(ツ)\\_/¯"
|
|
}
|
|
if (msg.startsWith("/tableflip")) {
|
|
return msg.replace("/tableflip", "") + " (╯°□°)╯︵ ┻━┻"
|
|
}
|
|
|
|
return msg
|
|
}
|
|
|
|
function isUploadFilePressed(event) {
|
|
return (event.key === Qt.Key_U) && (event.modifiers & Qt.ControlModifier) && imageBtn.visible && !imageDialog.visible
|
|
}
|
|
|
|
function checkTextInsert() {
|
|
if (emojiSuggestions.visible) {
|
|
emojiSuggestions.addEmoji();
|
|
return true
|
|
}
|
|
if (suggestionsBox.visible) {
|
|
suggestionsBox.selectCurrentItem();
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
function parseMarkdown(markdownText) {
|
|
const htmlText = markdownText
|
|
.replace(/\~\~([^*]+)\~\~/gim, '~~<span style="text-decoration: line-through">$1</span>~~')
|
|
.replace(/\*\*([^*]+)\*\*/gim, ':asterisk::asterisk:<b>$1</b>:asterisk::asterisk:')
|
|
.replace(/\`([^*]+)\`/gim, '`<code>$1</code>`')
|
|
.replace(/\*([^*]+)\*/gim, ':asterisk:<i>$1</i>:asterisk:')
|
|
return htmlText.replace(/\:asterisk\:/gim, "*")
|
|
}
|
|
|
|
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 (control.isStatusUpdateInput) {
|
|
return // Status update require the send button to be clicked
|
|
}
|
|
if (messageInputField.length < messageLimit) {
|
|
control.sendMessage(event)
|
|
control.hideExtendedArea();
|
|
event.accepted = true
|
|
return;
|
|
}
|
|
if(event) event.accepted = true
|
|
messageTooLongDialog.open()
|
|
}
|
|
|
|
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 newMessage = ""
|
|
if (message.data.endsWith("\n> ")) {
|
|
newMessage = message.data.substr(0, message.data.lastIndexOf("> ")) + "\n\n"
|
|
} else {
|
|
newMessage = message.data + "\n> ";
|
|
}
|
|
messageInputField.remove(0, messageInputField.cursorPosition);
|
|
insertInTextInput(0, Emoji.parse(newMessage));
|
|
}
|
|
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, Emoji.parse(newMessage));
|
|
event.accepted = true
|
|
}
|
|
}
|
|
|
|
if ((event.key === Qt.Key_V) && (event.modifiers & Qt.ControlModifier)) {
|
|
paste = true;
|
|
}
|
|
|
|
// ⌘⇧U
|
|
if (isUploadFilePressed(event)) {
|
|
imageBtn.clicked()
|
|
event.accepted = true
|
|
}
|
|
|
|
if (event.key === Qt.Key_Down) {
|
|
suggestionsBox.listView.incrementCurrentIndex()
|
|
return emojiSuggestions.listView.incrementCurrentIndex()
|
|
}
|
|
if (event.key === Qt.Key_Up) {
|
|
suggestionsBox.listView.decrementCurrentIndex()
|
|
return emojiSuggestions.listView.decrementCurrentIndex()
|
|
}
|
|
if (event.key === Qt.Key_Escape) {
|
|
suggestionsBox.hide()
|
|
}
|
|
|
|
isColonPressed = (event.key === Qt.Key_Colon) && (event.modifiers & Qt.ShiftModifier);
|
|
}
|
|
|
|
function wrapSelection(wrapWith) {
|
|
if (messageInputField.selectionStart - messageInputField.selectionEnd === 0) return
|
|
insertInTextInput(messageInputField.selectionStart, wrapWith);
|
|
insertInTextInput(messageInputField.selectionEnd, wrapWith);
|
|
messageInputField.deselect()
|
|
formatInputMessage()
|
|
}
|
|
|
|
function getPlainText() {
|
|
const textWithoutMention = messageInputField.text.replace(/<span style="[ :#0-9a-z;\-\.,\(\)]+">(@([a-z\.]+(\ ?[a-z]+\ ?[a-z]+)?))<\/span>/ig, "\[\[mention\]\]$1\[\[mention\]\]")
|
|
|
|
const deparsedEmoji = Emoji.deparse(textWithoutMention);
|
|
|
|
return chatsModel.plainText(deparsedEmoji);
|
|
}
|
|
|
|
function removeMentions(currentText) {
|
|
return currentText.replace(/\[\[mention\]\]/g, '')
|
|
}
|
|
|
|
function parseBackText(plainText) {
|
|
plainText = plainText.replace(/\[\[mention\]\](@([a-z\.]+(\ ?[a-z]+\ ?[a-z]+)?))\[\[mention\]\]/gi, `${Constants.mentionSpanTag}$1</span>`)
|
|
|
|
|
|
return parseMarkdown(Emoji.parse(plainText.replace(/\n/g, "<br />")))
|
|
}
|
|
|
|
function formatInputMessage() {
|
|
const posBeforeEnd = messageInputField.length - messageInputField.cursorPosition;
|
|
const plainText = getPlainText()
|
|
const formatted = parseBackText(plainText)
|
|
messageInputField.text = formatted
|
|
messageInputField.cursorPosition = messageInputField.length - posBeforeEnd;
|
|
}
|
|
|
|
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
|
|
if (paste) {
|
|
paste = false;
|
|
formatInputMessage()
|
|
interrogateMessage();
|
|
} else {
|
|
if (event.key === Qt.Key_Asterisk ||
|
|
event.key === Qt.Key_QuoteLeft ||
|
|
event.key === Qt.Key_Space ||
|
|
event.key === Qt.Key_AsciiTilde) {
|
|
formatInputMessage()
|
|
}
|
|
}
|
|
|
|
if (event.key !== Qt.Key_Escape) {
|
|
emojiEvent = emojiHandler(event);
|
|
if (!emojiEvent) {
|
|
emojiSuggestions.close()
|
|
}
|
|
}
|
|
}
|
|
|
|
function interrogateMessage() {
|
|
const text = chatsModel.plainText(Emoji.deparse(messageInputField.text));
|
|
|
|
var words = text.split(' ');
|
|
|
|
let madeChanges = false
|
|
let transform = true;
|
|
for (var i = 0; i < words.length; i++) {
|
|
transform = true;
|
|
if (words[i].charAt(0) === ':') {
|
|
for (var j = 0; j < words[i].length; j++) {
|
|
if (Utils.isSpace(words[i].charAt(j)) === true || Utils.isPunct(words[i].charAt(j)) === true) {
|
|
transform = false;
|
|
}
|
|
}
|
|
|
|
if (transform) {
|
|
madeChanges = true
|
|
const codePoint = Emoji.getEmojiUnicode(words[i]);
|
|
words[i] = words[i].replace(words[i], (codePoint !== undefined) ? Emoji.fromCodePoint(codePoint) : words[i]);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (madeChanges) {
|
|
messageInputField.remove(0, messageInputField.length);
|
|
insertInTextInput(0, Emoji.parse(words.join(' ')));
|
|
}
|
|
}
|
|
|
|
// 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 = 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 (<img>) and the mention tags ([[mention]])
|
|
const cursorPos = messageInputField.cursorPosition
|
|
for (var i = 0; i < plainText.length;) {
|
|
if (length >= cursorPos) break;
|
|
|
|
if (!bracketEvent && plainText.charAt(i) !== '<' && !mentionEvent && plainText.charAt(i) !== '[') {
|
|
length++;
|
|
} else if (!bracketEvent && plainText.charAt(i) === '<') {
|
|
bracketEvent = true;
|
|
} else if (bracketEvent && plainText.charAt(i) === '>') {
|
|
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
|
|
}
|
|
i++
|
|
}
|
|
|
|
let textBeforeCursor = 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 = 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 = 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 = Emoji.getEmojis(value);
|
|
var length = 0;
|
|
|
|
if (match && match.length > 0) {
|
|
for (var i = 0; i < match.length; i++) {
|
|
length += Emoji.deparseFromParse(match[i]).length;
|
|
}
|
|
length = length - match.length;
|
|
}
|
|
return length;
|
|
}
|
|
|
|
function replaceWithEmoji(message, shortname, codePoint) {
|
|
const encodedCodePoint = Emoji.getEmojiCodepoint(codePoint)
|
|
const newMessage = message.data
|
|
.replace(shortname, encodedCodePoint)
|
|
.replace(/ /g, " ");
|
|
messageInputField.remove(0, messageInputField.cursorPosition);
|
|
insertInTextInput(0, parseBackText(newMessage));
|
|
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) === true || Utils.isPunct(c) === true)
|
|
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.fileUrls = []
|
|
imageArea.imageSource = [];
|
|
replyArea.userName = ""
|
|
replyArea.identicon = ""
|
|
replyArea.message = ""
|
|
for (let i=0; i<validators.children.length; i++) {
|
|
const validator = validators.children[i]
|
|
validator.images = []
|
|
}
|
|
}
|
|
|
|
function validateImages(imagePaths) {
|
|
// needed because imageArea.imageSource is not a normal js array
|
|
const existing = (imageArea.imageSource || []).map(x => x.toString())
|
|
let validImages = Utils.deduplicate(existing.concat(imagePaths))
|
|
for (let i=0; i<validators.children.length; i++) {
|
|
const validator = validators.children[i]
|
|
validator.images = validImages
|
|
validImages = validImages.filter(validImage => validator.validImages.includes(validImage))
|
|
}
|
|
return validImages
|
|
}
|
|
|
|
function showImageArea(imagePaths) {
|
|
isImage = true;
|
|
isReply = false;
|
|
imageArea.imageSource = imagePaths
|
|
control.fileUrls = imageArea.imageSource
|
|
}
|
|
|
|
function showReplyArea(userName, message, identicon) {
|
|
isReply = true
|
|
replyArea.userName = userName
|
|
replyArea.message = message
|
|
replyArea.identicon = identicon
|
|
messageInputField.forceActiveFocus();
|
|
}
|
|
|
|
Connections {
|
|
target: applicationWindow.dragAndDrop
|
|
onDroppedOnValidScreen: (drop) => {
|
|
let validImages = validateImages(drop.urls)
|
|
if (validImages.length > 0) {
|
|
showImageArea(validImages)
|
|
drop.acceptProposedAction()
|
|
}
|
|
}
|
|
}
|
|
|
|
ListModel {
|
|
id: suggestions
|
|
}
|
|
|
|
FileDialog {
|
|
id: imageDialog
|
|
//% "Please choose an image"
|
|
title: qsTrId("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
|
|
imageBtn2.highlighted = false
|
|
let validImages = validateImages(imageDialog.fileUrls)
|
|
if (validImages.length > 0) {
|
|
control.showImageArea(validImages)
|
|
}
|
|
messageInputField.forceActiveFocus();
|
|
}
|
|
onRejected: {
|
|
imageBtn.highlighted = false
|
|
imageBtn2.highlighted = false
|
|
}
|
|
}
|
|
|
|
MessageDialog {
|
|
id: messageTooLongDialog
|
|
//% "Your message is too long."
|
|
title: qsTrId("your-message-is-too-long.")
|
|
icon: StandardIcon.Critical
|
|
//% "Please make your message shorter. We have set the limit to 2000 characters to be courteous of others."
|
|
text: qsTrId("please-make-your-message-shorter.-we-have-set-the-limit-to-2000-characters-to-be-courteous-of-others.")
|
|
standardButtons: StandardButton.Ok
|
|
}
|
|
|
|
StatusEmojiSuggestionPopup {
|
|
id: emojiSuggestions
|
|
}
|
|
|
|
SuggestionBox {
|
|
id: suggestionsBox
|
|
model: suggestions
|
|
x : messageInput.x
|
|
y: -height - Style.current.smallPadding
|
|
width: messageInput.width
|
|
filter: messageInputField.text
|
|
cursorPosition: messageInputField.cursorPosition
|
|
property: "ensName, localNickname, alias"
|
|
onItemSelected: function (item, lastAtPosition, lastCursorPosition) {
|
|
const hasEmoji = Emoji.hasEmoji(messageInputField.text)
|
|
const currentText = getPlainText()
|
|
|
|
const completelyPlainText = removeMentions(currentText)
|
|
|
|
lastAtPosition += currentText.length - completelyPlainText.length
|
|
lastCursorPosition += currentText.length - completelyPlainText.length
|
|
|
|
const properties = "ensName, alias"; // Ignore localNickname
|
|
|
|
let aliasName = item[properties.split(",").map(p => p.trim()).find(p => !!item[p])]
|
|
aliasName = aliasName.replace(/(\.stateofus)?\.eth/, "")
|
|
let nameLen = aliasName.length + 2 // We're doing a +2 here because of the `@` and the trailing whitespace
|
|
let position = 0;
|
|
let text = ""
|
|
const spanPlusAlias = `${Constants.mentionSpanTag}@${aliasName}</span> `
|
|
if (currentText === "@") {
|
|
position = nameLen
|
|
text = spanPlusAlias
|
|
} else {
|
|
let left = currentText.substring(0, lastAtPosition)
|
|
let right = currentText.substring(hasEmoji ? lastCursorPosition + 2 : lastCursorPosition)
|
|
text = `${left} ${spanPlusAlias}${right}`
|
|
}
|
|
|
|
messageInputField.text = parseBackText(text)
|
|
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
|
|
}
|
|
|
|
suggestionsBox.suggestionsModel.clear()
|
|
}
|
|
}
|
|
|
|
StatusChatCommandsPopup {
|
|
id: chatCommandsPopup
|
|
x: 8
|
|
y: -height
|
|
onSendTransactionCommandButtonClicked: {
|
|
control.sendTransactionCommandButtonClicked()
|
|
chatCommandsPopup.close()
|
|
}
|
|
onReceiveTransactionCommandButtonClicked: {
|
|
control.receiveTransactionCommandButtonClicked()
|
|
chatCommandsPopup.close()
|
|
}
|
|
onClosed: {
|
|
chatCommandsBtn.highlighted = false
|
|
}
|
|
}
|
|
|
|
StatusEmojiPopup {
|
|
id: emojiPopup
|
|
width: 360
|
|
height: 440
|
|
x: parent.width - width - Style.current.halfPadding
|
|
y: -height
|
|
emojiSelected: function (text, atCursor) {
|
|
insertInTextInput(atCursor ? messageInputField.cursorPosition : messageInputField.length, text)
|
|
emojiBtn.highlighted = false
|
|
messageInputField.forceActiveFocus();
|
|
}
|
|
onClosed: {
|
|
emojiBtn.highlighted = false
|
|
}
|
|
}
|
|
|
|
StatusStickersPopup {
|
|
id: stickersPopup
|
|
x: parent.width - width - Style.current.halfPadding
|
|
y: -height
|
|
enabled: !!control.recentStickers && !!control.stickerPackList
|
|
recentStickers: control.recentStickers
|
|
stickerPackList: control.stickerPackList
|
|
onStickerSelected: {
|
|
control.stickerSelected(hashId, packId)
|
|
messageInputField.forceActiveFocus();
|
|
}
|
|
onClosed: {
|
|
stickersBtn.highlighted = false
|
|
}
|
|
}
|
|
|
|
StatusIconButton {
|
|
id: chatCommandsBtn
|
|
icon.name: "chat-commands"
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: 4
|
|
anchors.bottom: parent.bottom
|
|
anchors.bottomMargin: 16
|
|
visible: control.chatType === Constants.chatTypeOneToOne && !control.isStatusUpdateInput
|
|
onClicked: {
|
|
highlighted = true
|
|
chatCommandsPopup.open()
|
|
}
|
|
}
|
|
|
|
StatusIconButton {
|
|
id: imageBtn
|
|
icon.name: "images_icon"
|
|
icon.height: 18
|
|
icon.width: 20
|
|
anchors.left: chatCommandsBtn.visible ? chatCommandsBtn.right : parent.left
|
|
anchors.leftMargin: chatCommandsBtn.visible ? 2 : 4
|
|
anchors.bottom: parent.bottom
|
|
anchors.bottomMargin: 16
|
|
visible: control.chatType !== Constants.chatTypePublic && !control.isStatusUpdateInput
|
|
|
|
onClicked: {
|
|
highlighted = true
|
|
imageDialog.open()
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
id: messageInput
|
|
property int maxInputFieldHeight: control.isStatusUpdateInput ? 124 : 112
|
|
property int defaultInputFieldHeight: control.isStatusUpdateInput ? 56 : 40
|
|
anchors.left: imageBtn.visible ? imageBtn.right : parent.left
|
|
anchors.leftMargin: imageBtn.visible ? 5 : Style.current.smallPadding
|
|
anchors.top: control.isStatusUpdateInput ? parent.top : undefined
|
|
anchors.bottom: !control.isStatusUpdateInput ? parent.bottom : undefined
|
|
anchors.bottomMargin: control.isStatusUpdateInput ? 0 : 12
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: Style.current.smallPadding
|
|
height: {
|
|
if (messageInputField.implicitHeight <= messageInput.defaultInputFieldHeight) {
|
|
return messageInput.defaultInputFieldHeight
|
|
}
|
|
if (messageInputField.implicitHeight >= messageInput.maxInputFieldHeight) {
|
|
return messageInput.maxInputFieldHeight
|
|
}
|
|
return messageInputField.implicitHeight
|
|
}
|
|
|
|
color: Style.current.inputBackground
|
|
radius: control.isStatusUpdateInput ? 36 :
|
|
height > defaultInputFieldHeight + 1 || extendedArea.visible ? 16 : 32
|
|
|
|
ColumnLayout {
|
|
id: validators
|
|
anchors.bottom: control.imageErrorMessageLocation === StatusChatInput.ImageErrorMessageLocation.Top ? extendedArea.top : undefined
|
|
anchors.bottomMargin: control.imageErrorMessageLocation === StatusChatInput.ImageErrorMessageLocation.Top ? -4 : undefined
|
|
anchors.top: control.imageErrorMessageLocation === StatusChatInput.ImageErrorMessageLocation.Bottom ? extendedArea.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 {
|
|
id: extendedArea
|
|
visible: isImage || isReply
|
|
height: {
|
|
if (visible) {
|
|
if (isImage) {
|
|
return imageArea.height
|
|
}
|
|
|
|
if (isReply) {
|
|
return replyArea.height + replyArea.anchors.topMargin
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
anchors.left: messageInput.left
|
|
anchors.right: messageInput.right
|
|
anchors.bottom: control.isStatusUpdateInput ? undefined : messageInput.top
|
|
anchors.top: control.isStatusUpdateInput ? messageInput.bottom : undefined
|
|
anchors.topMargin: control.isStatusUpdateInput ? -Style.current.halfPadding : 0
|
|
color: Style.current.inputBackground
|
|
radius: control.isStatusUpdateInput ? 36 : 16
|
|
|
|
Rectangle {
|
|
color: parent.color
|
|
anchors.right: parent.right
|
|
anchors.left: parent.left
|
|
height: control.isStatusUpdateInput ? 64 : 30
|
|
anchors.top: control.isStatusUpdateInput ? parent.top : undefined
|
|
anchors.topMargin: control.isStatusUpdateInput ? -24 : 0
|
|
anchors.bottom: control.isStatusUpdateInput ? undefined : parent.bottom
|
|
anchors.bottomMargin: control.isStatusUpdateInput ? 0 : -height/2
|
|
}
|
|
|
|
StatusChatInputImageArea {
|
|
id: imageArea
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: control.isStatusUpdateInput ? profileImage.width + Style.current.padding : Style.current.halfPadding
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: control.isStatusUpdateInput ? actions.width + 2* Style.current.padding : Style.current.halfPadding
|
|
anchors.top: parent.top
|
|
anchors.topMargin: control.isStatusUpdateInput ? 0 : Style.current.halfPadding
|
|
visible: isImage
|
|
width: messageInputField.width - actions.width
|
|
onImageRemoved: {
|
|
if (control.fileUrls.length > index && control.fileUrls[index]) {
|
|
control.fileUrls.splice(index, 1)
|
|
}
|
|
isImage = control.fileUrls.length > 0
|
|
validateImages(control.fileUrls)
|
|
}
|
|
}
|
|
|
|
StatusChatInputReplyArea {
|
|
id: replyArea
|
|
visible: isReply
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: 2
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 2
|
|
anchors.top: parent.top
|
|
anchors.topMargin: 2
|
|
onCloseButtonClicked: {
|
|
isReply = false
|
|
}
|
|
}
|
|
}
|
|
|
|
StatusImageIdenticon {
|
|
id: profileImage
|
|
source: profileModel.profile.identicon
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: Style.current.smallPadding
|
|
anchors.top: parent.top
|
|
anchors.topMargin: Style.current.halfPadding
|
|
visible: control.isStatusUpdateInput
|
|
}
|
|
|
|
ScrollView {
|
|
id: scrollView
|
|
anchors.top: parent.top
|
|
anchors.bottom: parent.bottom
|
|
anchors.left: profileImage.visible ? profileImage.right : parent.left
|
|
anchors.leftMargin: Style.current.smallPadding
|
|
anchors.right: actions.left
|
|
anchors.rightMargin: Style.current.halfPadding
|
|
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
|
|
|
|
TextArea {
|
|
id: messageInputField
|
|
textFormat: Text.RichText
|
|
font.pixelSize: 15
|
|
font.family: Style.current.fontRegular.name
|
|
wrapMode: TextArea.Wrap
|
|
//% "Type a message"
|
|
placeholderText: qsTrId("type-a-message")
|
|
placeholderTextColor: Style.current.secondaryText
|
|
selectByMouse: true
|
|
color: Style.current.textColor
|
|
topPadding: control.isStatusUpdateInput ? 18 : Style.current.smallPadding
|
|
bottomPadding: control.isStatusUpdateInput ? 14 : 12
|
|
Keys.onPressed: onKeyPress(event)
|
|
Keys.onReleased: onRelease(event) // gives much more up to date cursorPosition
|
|
Keys.onShortcutOverride: event.accepted = isUploadFilePressed(event)
|
|
leftPadding: 0
|
|
background: Rectangle {
|
|
color: "transparent"
|
|
}
|
|
selectionColor: Style.current.primarySelectionColor
|
|
persistentSelection: true
|
|
onReleased: function (event) {
|
|
if (messageInputField.selectedText.trim() !== "") {
|
|
let x = event.x - (textFormatMenu.width / 2)
|
|
textFormatMenu.popup(x, -messageInputField.height-2)
|
|
messageInputField.forceActiveFocus();
|
|
}
|
|
}
|
|
|
|
StatusTextFormatMenu {
|
|
id: textFormatMenu
|
|
function surroundedBy(chars) {
|
|
return messageInputField.selectedText.trim().startsWith(chars) &&
|
|
messageInputField.selectedText.trim().endsWith(chars)
|
|
}
|
|
Action {
|
|
icon.name: "format-text-bold"
|
|
icon.width: 12
|
|
icon.height: 16
|
|
onTriggered: wrapSelection("**")
|
|
//% "Bold"
|
|
text: qsTrId("bold")
|
|
checked: textFormatMenu.surroundedBy("**")
|
|
}
|
|
Action {
|
|
icon.name: "format-text-italic"
|
|
icon.width: 12
|
|
icon.height: 16
|
|
onTriggered: wrapSelection("*")
|
|
//% "Italic"
|
|
text: qsTrId("italic")
|
|
checked: textFormatMenu.surroundedBy("*") && !textFormatMenu.surroundedBy("**")
|
|
}
|
|
Action {
|
|
icon.name: "format-text-strike-through"
|
|
icon.width: 20
|
|
icon.height: 18
|
|
onTriggered: wrapSelection("~~")
|
|
//% "Strikethrough"
|
|
text: qsTrId("strikethrough")
|
|
checked: textFormatMenu.surroundedBy("~~")
|
|
}
|
|
Action {
|
|
icon.name: "format-text-code"
|
|
icon.width: 20
|
|
icon.height: 18
|
|
onTriggered: wrapSelection("`")
|
|
//% "Code"
|
|
text: qsTrId("code")
|
|
checked: textFormatMenu.surroundedBy("`")
|
|
}
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
}
|
|
|
|
Rectangle {
|
|
color: parent.color
|
|
anchors.bottom: parent.bottom
|
|
anchors.right: parent.right
|
|
visible: !control.isStatusUpdateInput
|
|
height: parent.height / 2
|
|
width: 32
|
|
radius: Style.current.radius
|
|
}
|
|
|
|
StyledText {
|
|
id: messageLengthLimit
|
|
property int remainingChars: messageLimit - messageInputField.length
|
|
text: remainingChars.toString()
|
|
visible: remainingChars <= control.messageLimitVisible
|
|
color: (remainingChars <= 0) ? Style.current.danger : Style.current.textColor
|
|
anchors.right: parent.right
|
|
anchors.bottom: actions.top
|
|
anchors.rightMargin: control.isStatusUpdateInput ? Style.current.padding : Style.current.radius
|
|
leftPadding: Style.current.halfPadding
|
|
rightPadding: Style.current.halfPadding
|
|
}
|
|
|
|
Item {
|
|
id: actions
|
|
width: control.isStatusUpdateInput ?
|
|
imageBtn2.width + sendBtn.anchors.leftMargin + sendBtn.width :
|
|
emojiBtn.width + stickersBtn.anchors.leftMargin + stickersBtn.width
|
|
anchors.bottom: control.isStatusUpdateInput && extendedArea.visible ? extendedArea.bottom : parent.bottom
|
|
anchors.bottomMargin: control.isStatusUpdateInput ? Style.current.smallPadding+2: 4
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: Style.current.radius
|
|
height: emojiBtn.height
|
|
|
|
StatusIconButton {
|
|
id: imageBtn2
|
|
icon.name: "images_icon"
|
|
icon.height: 18
|
|
icon.width: 20
|
|
anchors.right: sendBtn.left
|
|
anchors.rightMargin: 2
|
|
anchors.bottom: parent.bottom
|
|
visible: control.isStatusUpdateInput
|
|
|
|
onClicked: {
|
|
highlighted = true
|
|
imageDialog.open()
|
|
}
|
|
}
|
|
|
|
StatusButton {
|
|
id: sendBtn
|
|
icon.source: "../../app/img/send.svg"
|
|
icon.width: 16
|
|
icon.height: 18
|
|
borderRadius: 16
|
|
//% "Send"
|
|
text: qsTrId("command-button-send")
|
|
type: "secondary"
|
|
flat: true
|
|
showBorder: true
|
|
forceBgColorOnHover: true
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: Style.current.halfPadding
|
|
anchors.bottom: parent.bottom
|
|
visible: imageBtn2.visible
|
|
highlighted: chatsModel.plainText(Emoji.deparse(messageInputField.text)).length > 0 || isImage
|
|
enabled: highlighted && messageInputField.length < messageLimit
|
|
onClicked: function (event) {
|
|
control.sendMessage(event)
|
|
control.hideExtendedArea();
|
|
}
|
|
}
|
|
|
|
StatusIconButton {
|
|
id: emojiBtn
|
|
visible: !imageBtn2.visible
|
|
anchors.left: parent.left
|
|
anchors.bottom: parent.bottom
|
|
icon.name: "emojiBtn"
|
|
type: "secondary"
|
|
onClicked: {
|
|
stickersPopup.close()
|
|
if (emojiPopup.opened) {
|
|
emojiPopup.close()
|
|
highlighted = false
|
|
} else {
|
|
emojiPopup.open()
|
|
highlighted = true
|
|
}
|
|
}
|
|
}
|
|
|
|
StatusIconButton {
|
|
id: stickersBtn
|
|
anchors.left: emojiBtn.right
|
|
anchors.leftMargin: 2
|
|
anchors.bottom: parent.bottom
|
|
icon.name: "stickers_icon"
|
|
visible: profileModel.network.current === Constants.networkMainnet && emojiBtn.visible
|
|
width: visible ? 32 : 0
|
|
type: "secondary"
|
|
onClicked: {
|
|
emojiPopup.close()
|
|
if (stickersPopup.opened) {
|
|
stickersPopup.close()
|
|
highlighted = false
|
|
} else {
|
|
stickersPopup.open()
|
|
highlighted = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|