mirror of
https://github.com/status-im/status-desktop.git
synced 2025-01-09 22:06:25 +00:00
4a30d13bdc
* feat(StatusQ): Adding numberToLocaleStringInCompactForm function to LocaleUtils This function will format the number in a compact form E.g: 1000 -> 1K; 1000000 -> 1M; 1100000 -> 1.1M + adding tests fix(statusQ): Update numberToLocaleStringInCompactForm to return the locale number when greter than 999T fix(StatusQ): extend the test_numberToLocaleStringInCompactForm with new data * feat(LinkPreviews): Update the link preview area in StatusChatInput to use the new model Changes: 1. Create a new component `LinkPreviewMiniCardDelegate.qml` that filters the model data to properly fill the link preview card with the needed data based on the preview type 2. Update storybook pages 3. Small updates to LinkPreviewMiniCard * feat(LinkPreviews): Update the link previews in message history to use the new backend Changes: 1. Create delegate items for LinkPreviewCard and gif link preview to clean the LinksMessageView component and filter the model data based on the preview type 2. Remove UserProfileCard and reuse the LinkPreviewCard to display contacts link previews 3. Update LinkPreviewCard so that it can accommodate status link previews (communities, channels, contacts). The generic properties (title, description, footer) have been dropped and replaced with specialised properties for each preview type. 4. Fix LinkPreviewCard layout to better accommodate different content variants (missing properties, long/short title, missing description, missing icon) 5. Fixing the link preview context menu and click actions fix: Move inline components to separate files Fixing the linux builds using Qt 5.15.2 affected by this bug: https://bugreports.qt.io/browse/QTBUG-89180 * fix: Align LinkPreviewMiniCard implementation with LinkPreviewCard and remove state based model filtering
1523 lines
65 KiB
QML
1523 lines
65 KiB
QML
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 urlsModel: 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("<a href=", index)
|
|
if (index < 0) {
|
|
break
|
|
}
|
|
const startIndex = index
|
|
const endIndex = message.indexOf("</a>", index) + 4
|
|
if (endIndex < 0) {
|
|
index += 8 // "<a href="
|
|
continue
|
|
}
|
|
const addrIndex = message.indexOf("0x", index + 8)
|
|
if (addrIndex < 0) {
|
|
index += 8 // "<a href="
|
|
continue
|
|
}
|
|
const addrEndIndex = message.indexOf("\"", addrIndex)
|
|
if (addrEndIndex < 0) {
|
|
index += 8 // "<a href="
|
|
continue
|
|
}
|
|
const mentionLink = message.substring(startIndex, endIndex)
|
|
const linkTag = message.substring(index, endIndex)
|
|
const linkText = linkTag.replace(/(<([^>]+)>)/ig,"").trim()
|
|
const atSymbol = linkText.startsWith("@") ? '' : '@'
|
|
const mentionTag = Constants.mentionSpanTag + atSymbol + linkText + '</span> '
|
|
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) {
|
|
const textInputEnabled = textInput.enabled
|
|
if(textInputEnabled) {
|
|
textInput.enabled = false
|
|
}
|
|
|
|
textInput.clear()
|
|
textInput.append(text)
|
|
textInput.enabled = textInputEnabled
|
|
}
|
|
|
|
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}</a></span> `;
|
|
|
|
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, "<br/>"));
|
|
}
|
|
|
|
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) ?
|
|
("<div style='white-space: pre-wrap'>" + StatusQUtils.StringUtils.escapeHtml(QClipboardProxy.text) + "</div>")
|
|
: StatusQUtils.Emoji.deparse(QClipboardProxy.html)));
|
|
}
|
|
event.accepted = true
|
|
}
|
|
}
|
|
|
|
// ⌘⇧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;
|
|
}
|
|
|
|
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
|
|
|
|
// Calculate 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(/<span style="[ :#0-9a-z;\-\.,\(\)]+">(@([a-z\.]+(\ ?[a-z]+\ ?[a-z]+)?))<\/span>/ig, "\[\[mention\]\]$1\[\[mention\]\]")
|
|
|
|
const deparsedEmoji = StatusQUtils.Emoji.deparse(textWithoutMention);
|
|
|
|
return Utils.plainText(deparsedEmoji)
|
|
}
|
|
|
|
function removeMentions(currentText) {
|
|
return currentText.replace(/\[\[mention\]\]/g, '')
|
|
}
|
|
|
|
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 getFormattedText(start, end) {
|
|
start = start || 0
|
|
end = end || messageInputField.length
|
|
|
|
const oldFormattedText = messageInputField.getFormattedText(start, end)
|
|
|
|
const found = oldFormattedText.match(/<!--StartFragment-->([\w\W\s]*)<!--EndFragment-->/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 (<img>) 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<validators.children.length; i++) {
|
|
const validator = validators.children[i]
|
|
validator.images = []
|
|
}
|
|
}
|
|
|
|
function resetReplyArea() {
|
|
isReply = false
|
|
replyArea.messageId = ""
|
|
}
|
|
|
|
function hideExtendedArea() {
|
|
resetImageArea()
|
|
resetReplyArea()
|
|
}
|
|
|
|
function validateImages(imagePaths) {
|
|
if (!imagePaths || !imagePaths.length) {
|
|
return []
|
|
}
|
|
// needed because control.fileUrlsAndSources is not a normal js array
|
|
const existing = (control.fileUrlsAndSources || []).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(imagePathsOrData) {
|
|
isImage = imagePathsOrData.length > 0
|
|
control.fileUrlsAndSources = imagePathsOrData
|
|
}
|
|
|
|
// Use this to validate and show the images. The concatenation of previous selected images is done automatically
|
|
// Returns true if the images were valid and added
|
|
function validateImagesAndShowImageArea(imagePaths) {
|
|
const validImages = validateImages(imagePaths)
|
|
showImageArea(validImages)
|
|
return isImage
|
|
}
|
|
|
|
function showReplyArea(messageId, userName, message, contentType, image, album, albumCount, sticker) {
|
|
isReply = true
|
|
replyArea.userName = userName
|
|
replyArea.message = message
|
|
replyArea.contentType = contentType
|
|
replyArea.image = image
|
|
replyArea.stickerData = sticker
|
|
replyArea.messageId = messageId
|
|
replyArea.album = album
|
|
replyArea.albumCount = albumCount
|
|
messageInputField.forceActiveFocus();
|
|
}
|
|
|
|
function forceInputActiveFocus() {
|
|
messageInputField.forceActiveFocus();
|
|
}
|
|
|
|
Connections {
|
|
target: Global.dragArea
|
|
enabled: control.visible
|
|
ignoreUnknownSignals: true
|
|
function onDroppedOnValidScreen(drop) {
|
|
let dropUrls = drop.urls
|
|
if (!drop.hasUrls) {
|
|
console.warn("Trying to drop, list of URLs is empty tho; formats:", drop.formats)
|
|
if (drop.formats.includes("text/x-moz-url")) { // Chrome uses a non-standard MIME type
|
|
dropUrls = drop.getDataAsString("text/x-moz-url")
|
|
}
|
|
}
|
|
|
|
if (validateImagesAndShowImageArea(dropUrls)) {
|
|
drop.acceptProposedAction()
|
|
}
|
|
}
|
|
}
|
|
|
|
// This is used by Squish tests to not have to access the file dialog
|
|
function selectImageString(filePath) {
|
|
validateImagesAndShowImageArea([filePath])
|
|
messageInputField.forceActiveFocus();
|
|
}
|
|
|
|
Component {
|
|
id: imageDialogComponent
|
|
|
|
FileDialog {
|
|
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
|
|
validateImagesAndShowImageArea(fileUrls)
|
|
messageInputField.forceActiveFocus()
|
|
}
|
|
onRejected: {
|
|
imageBtn.highlighted = false
|
|
}
|
|
}
|
|
}
|
|
|
|
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: ["nickname", "ensName", "name"]
|
|
inputField: messageInputField
|
|
onItemSelected: function (item, lastAtPosition, lastCursorPosition) {
|
|
messageInputField.forceActiveFocus();
|
|
const name = item[suggestionsBox.property.find(p => !!item[p])].replace("@", "")
|
|
d.insertMention(name, item.publicKey, lastAtPosition, lastCursorPosition)
|
|
suggestionsBox.suggestionsModel.clear()
|
|
}
|
|
onVisibleChanged: {
|
|
if (!visible) {
|
|
messageInputField.forceActiveFocus();
|
|
}
|
|
}
|
|
}
|
|
|
|
Component {
|
|
id: gifPopupComponent
|
|
|
|
StatusGifPopup {
|
|
readonly property point relativePosition: d.getCommonPopupRelativePosition(this, parent)
|
|
|
|
width: 360
|
|
height: 440
|
|
x: relativePosition.x
|
|
y: relativePosition.y
|
|
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutsideParent
|
|
|
|
gifSelected: function (event, url) {
|
|
messageInputField.text += "\n" + url
|
|
control.sendMessage(event)
|
|
control.isReply = false
|
|
messageInputField.forceActiveFocus()
|
|
if (control.closeGifPopupAfterSelection)
|
|
close()
|
|
}
|
|
onClosed: {
|
|
gifBtn.popup = null
|
|
destroy()
|
|
}
|
|
}
|
|
}
|
|
|
|
RowLayout {
|
|
id: layout
|
|
anchors.fill: parent
|
|
spacing: 4
|
|
|
|
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
|
|
onClicked: {
|
|
highlighted = true
|
|
const popup = imageDialogComponent.createObject(control)
|
|
popup.open()
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
id: messageInput
|
|
|
|
Layout.fillWidth: true
|
|
implicitHeight: inputLayout.implicitHeight + inputLayout.anchors.topMargin + inputLayout.anchors.bottomMargin
|
|
implicitWidth: inputLayout.implicitWidth + inputLayout.anchors.leftMargin + inputLayout.anchors.rightMargin
|
|
|
|
color: isEdit ? Theme.palette.statusChatInput.secondaryBackgroundColor : Style.current.inputBackground
|
|
radius: 20
|
|
|
|
StatusQ.StatusToolTip {
|
|
id: messageLengthLimitTooltip
|
|
text: messageInputField.length >= control.messageLimitHard ? qsTr("Please reduce the message length")
|
|
: qsTr("Maximum message character count is %n", "", control.messageLimit)
|
|
orientation: StatusQ.StatusToolTip.Orientation.Top
|
|
timeout: 3000 // show for 3 seconds
|
|
}
|
|
|
|
Component {
|
|
id: textFormatMenuComponent
|
|
|
|
StatusTextFormatMenu {
|
|
onClosed: {
|
|
messageInputField.deselect()
|
|
destroy()
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
width: parent.width
|
|
spacing: 4
|
|
|
|
StatusChatInputReplyArea {
|
|
id: replyArea
|
|
visible: isReply
|
|
Layout.fillWidth: true
|
|
Layout.margins: 2
|
|
onCloseButtonClicked: {
|
|
isReply = false
|
|
}
|
|
}
|
|
|
|
ChatInputLinksPreviewArea {
|
|
id: linkPreviewArea
|
|
Layout.fillWidth: true
|
|
visible: hasContent
|
|
horizontalPadding: 12
|
|
topPadding: 12
|
|
imagePreviewArray: control.fileUrlsAndSources
|
|
linkPreviewModel: control.linkPreviewModel
|
|
showLinkPreviewSettings: control.askToEnableLinkPreview
|
|
onImageRemoved: (index) => {
|
|
//Just do a copy and replace the whole thing because it's a plain JS array and thre's no signal when a single item is removed
|
|
let urls = control.fileUrlsAndSources
|
|
if (urls.length > index && urls[index]) {
|
|
urls.splice(index, 1)
|
|
}
|
|
control.fileUrlsAndSources = urls
|
|
}
|
|
onImageClicked: (chatImage) => Global.openImagePopup(chatImage, "")
|
|
onLinkReload: (link) => control.linkPreviewReloaded(link)
|
|
onLinkClicked: (link) => Global.openLink(link)
|
|
onEnableLinkPreview: () => control.enableLinkPreview()
|
|
onEnableLinkPreviewForThisMessage: () => control.enableLinkPreviewForThisMessage()
|
|
onDisableLinkPreview: () => control.disableLinkPreview()
|
|
onDismissLinkPreviewSettings: () => control.dismissLinkPreviewSettings()
|
|
onDismissLinkPreview: (index) => control.dismissLinkPreview(index)
|
|
}
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
Layout.minimumHeight: (messageInputField.contentHeight + messageInputField.topPadding + messageInputField.bottomPadding)
|
|
Layout.maximumHeight: 200
|
|
spacing: Style.current.radius
|
|
StatusScrollView {
|
|
id: inputScrollView
|
|
Layout.fillWidth: true
|
|
Layout.fillHeight: true
|
|
Layout.leftMargin: 12
|
|
Layout.rightMargin: 12
|
|
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
|
|
|
|
padding: 0
|
|
contentWidth: availableWidth
|
|
|
|
TextArea {
|
|
id: messageInputField
|
|
|
|
objectName: "messageInputField"
|
|
|
|
property var lastClick: 0
|
|
property int cursorWhenPressed: 0
|
|
property int previousCursorPosition: 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.onUpPressed: {
|
|
if (isEdit && !activeFocus) {
|
|
forceActiveFocus();
|
|
} else {
|
|
if (messageInputField.length === 0) {
|
|
control.keyUpPress();
|
|
}
|
|
}
|
|
if (emojiSuggestions.visible) {
|
|
emojiSuggestions.listView.decrementCurrentIndex();
|
|
}
|
|
event.accepted = false
|
|
}
|
|
Keys.onPressed: {
|
|
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 && ((keyEvent.key === Qt.Key_Left) || (keyEvent.key === Qt.Key_Right)
|
|
|| (selectedText.length>0))) {
|
|
const mention = d.getMentionAtPosition(cursorPosition)
|
|
if (mention) {
|
|
const cursorMovingLeft = (cursorPosition < previousCursorPosition);
|
|
const newCursorPosition = cursorMovingLeft ?
|
|
mention.leftIndex :
|
|
mention.rightIndex
|
|
const isSelection = (selectedText.length>0);
|
|
isSelection ? moveCursorSelection(newCursorPosition, TextEdit.SelectCharacters) :
|
|
cursorPosition = newCursorPosition
|
|
}
|
|
}
|
|
|
|
inputScrollView.ensureVisible(cursorRectangle)
|
|
previousCursorPosition = cursorPosition
|
|
}
|
|
|
|
onTextChanged: {
|
|
if (length <= control.messageLimit) {
|
|
if (length === 0) {
|
|
mentionsPos = [];
|
|
} else {
|
|
checkForInlineEmojis()
|
|
}
|
|
} else if (length > control.messageLimitHard) {
|
|
const removeFrom = (cursorPosition < messageLimitHard) ? cursorWhenPressed : messageLimitHard;
|
|
remove(removeFrom, cursorPosition);
|
|
messageLengthLimitTooltip.open();
|
|
}
|
|
|
|
d.updateMentionsPositions()
|
|
d.cleanMentionsPos()
|
|
|
|
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)
|
|
const x = now < messageInputField.lastClick + 500
|
|
? event.x
|
|
: (messageInputField.cursorRectangle.x + event.x) / 2
|
|
let menu = Global.openMenu(textFormatMenuComponent, messageInput, {})
|
|
menu.x = x - menu.width / 2
|
|
menu.y = messageInputField.y - menu.height - 5
|
|
d.textFormatMenu = menu
|
|
messageInputField.forceActiveFocus()
|
|
}
|
|
lastClick = now
|
|
}
|
|
|
|
onLinkActivated: {
|
|
const mention = d.getMentionAtPosition(cursorPosition - 1)
|
|
if(mention) {
|
|
select(mention.leftIndex, mention.rightIndex)
|
|
}
|
|
}
|
|
|
|
onEnabledChanged: {
|
|
if (!enabled) {
|
|
clear()
|
|
control.hideExtendedArea()
|
|
}
|
|
}
|
|
|
|
cursorDelegate: StatusCursorDelegate {
|
|
cursorVisible: messageInputField.cursorVisible
|
|
}
|
|
|
|
StatusSyntaxHighlighter {
|
|
quickTextDocument: messageInputField.textDocument
|
|
codeBackgroundColor: Style.current.codeBackground
|
|
codeForegroundColor: Style.current.textColor
|
|
}
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
acceptedButtons: Qt.NoButton
|
|
enabled: parent.hoveredLink
|
|
cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.IBeamCursor
|
|
}
|
|
}
|
|
|
|
TextEditHyperlinksFormatter {
|
|
id: hyperlinksFormatter
|
|
textEdit: messageInputField
|
|
urlModel: control.urlsModel
|
|
highlightUrl: linkPreviewArea.hoveredUrl
|
|
enabled: messageInputField.enabled && messageInputField.textFormat == TextEdit.RichText
|
|
}
|
|
|
|
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.messageLimit - control.messageLimitSoft
|
|
color: {
|
|
if (remainingChars >= 0)
|
|
return Style.current.textColor
|
|
else
|
|
return Style.current.danger
|
|
}
|
|
text: visible ? remainingChars.toString() : ""
|
|
|
|
MouseArea {
|
|
id: mouseArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
onEntered: {
|
|
messageLengthLimitTooltip.open()
|
|
}
|
|
onExited: {
|
|
messageLengthLimitTooltip.hide()
|
|
}
|
|
}
|
|
}
|
|
|
|
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"
|
|
highlighted: d.emojiPopupOpened
|
|
onClicked: {
|
|
if (d.emojiPopupOpened) {
|
|
emojiPopup.close()
|
|
return
|
|
}
|
|
d.emojiPopupOpened = true
|
|
emojiPopup.open()
|
|
}
|
|
}
|
|
|
|
StatusQ.StatusFlatRoundButton {
|
|
id: gifBtn
|
|
|
|
property Popup popup
|
|
|
|
objectName: "gifPopupButton"
|
|
implicitHeight: 32
|
|
implicitWidth: 32
|
|
visible: !isEdit
|
|
icon.name: "gif"
|
|
icon.color: (hovered || highlighted) ? Theme.palette.primaryColor1
|
|
: Theme.palette.baseColor1
|
|
type: StatusQ.StatusFlatRoundButton.Type.Tertiary
|
|
color: "transparent"
|
|
highlighted: popup && popup.opened
|
|
onClicked: {
|
|
if (popup) {
|
|
popup.close()
|
|
return
|
|
}
|
|
popup = gifPopupComponent.createObject(gifBtn)
|
|
popup.open()
|
|
}
|
|
}
|
|
|
|
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"
|
|
highlighted: d.stickersPopupOpened
|
|
onClicked: {
|
|
if (d.stickersPopupOpened) {
|
|
control.stickersPopup.close()
|
|
return
|
|
}
|
|
d.stickersPopupOpened = true
|
|
control.stickersPopup.open()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|