Fix Mentions in statusChatInput

1. Don't allow mention invalidation by altering mention with mouse or keyboard
2. Don't allow mention duplication on the same position
3. Clean mentions after text with mentions changes
4. Fix mention selection
5. Make sure mention is separated by text with valid separators (we're using only space)
6. Cursor will consider the mention as an object and will jump over it as it would be a single character (placing cursor inside leaves room for mention invalidation)
This commit is contained in:
Alex Jbanca 2022-12-15 12:29:10 +02:00 committed by Alex Jbanca
parent 94d7c478eb
commit 6cba8810e0
3 changed files with 260 additions and 63 deletions

View File

@ -12,6 +12,8 @@ import shared.stores 1.0
SplitView { SplitView {
id: root id: root
Logs { id: logs }
QtObject { QtObject {
id: globalUtilsMock id: globalUtilsMock
@ -90,8 +92,22 @@ SplitView {
usersStore: QtObject { usersStore: QtObject {
readonly property var usersModel: fakeUsersModel readonly property var usersModel: fakeUsersModel
} }
onSendMessage: {
logs.logEvent("StatusChatInput::sendMessage", ["MessageWithPk"], [chatInput.getTextWithPublicKeys()])
logs.logEvent("StatusChatInput::sendMessage", ["PlainText"], [globalUtilsMock.globalUtils.plainText(chatInput.getTextWithPublicKeys())])
logs.logEvent("StatusChatInput::sendMessage", ["RawText"], [chatInput.textInput.text])
}
} }
} }
LogsAndControlsPanel {
id: logsAndControlsPanel
SplitView.minimumHeight: 100
SplitView.preferredHeight: 200
logsView.logText: logs.logText
}
} }
Pane { Pane {

View File

@ -464,6 +464,164 @@ Item {
} }
} }
TestCase {
id: statusChatInputMentions
name: "StatusChatInputMentions"
when: windowShown
property StatusChatInput controlUnderTest: null
function init() {
Utils.globalUtilsInst = globalUtilsMock
controlUnderTest = createTemporaryObject(componentUnderTest, root)
}
//Scenario: Mention behaves like a single character when moving cursor by keyboard
//Given the user has contact <mention>
//And typed message <textInput>
//And has placed cursor at mention <initialPosition>
//When the user hits <key>
//The cursor moves to <expectedPosition>
//Example:
//| textInput | mention | initialPosition | key | expectedPosition |
//| Hello @JohnDoe! | | 6 | right | 7 |
//| Hello @JohnDoe! | | 14 | left | 13 |
//| Hello @JohnDoe! | JohnDoe | 6 | right | 14 |
//| Hello @JohnDoe! | JohnDoe | 14 | left | 6 |
function test_mention_is_skipped_by_cursor_data() {
return [
{tag: "MoveRightNoMention", textInput: "Hello @JohnDoe!", mention: "", initialPosition: 6, key: Qt.Key_Right, expectedPosition: 7},
{tag: "MoveLeftNoMention", textInput: "Hello @JohnDoe!", mention: "", initialPosition: 14, key: Qt.Key_Left, expectedPosition: 13},
{tag: "MoveRightWithMention", textInput: "Hello @JohnDoe!", mention: "JohnDoe", initialPosition: 6, key: Qt.Key_Right, expectedPosition: 14},
{tag: "MoveLeftWithMention", textInput: "Hello @JohnDoe!", mention: "JohnDoe", initialPosition: 14, key: Qt.Key_Left, expectedPosition: 6}
]
}
function test_mention_is_skipped_by_cursor(data) {
if(data.mention !== "") {
testHelper.when_the_user_has_contact(controlUnderTest, data.mention, (contact) => {})
}
testHelper.when_text_is_typed(statusChatInputMentions,
data.textInput, (typedText) => {})
controlUnderTest.textInput.cursorPosition = data.initialPosition
compare(controlUnderTest.textInput.cursorPosition, data.initialPosition)
keyClick(data.key)
compare(controlUnderTest.textInput.cursorPosition, data.expectedPosition)
}
//Scenario: Mention behaves like a single character when selecting by keyboard
//Given the user has contact <mention>
//And typed message <textInput>
//And has placed cursor at mention <initialPosition>
//When the user hits <key> holding Shift
//The selected text is <expectedSelection>
//Example:
//| textInput | mention | initialPosition | key | expectedSelection |
//| Hello @JohnDoe! | | 6 | right | @ |
//| Hello @JohnDoe! | | 14 | left | e |
//| Hello @JohnDoe! | JohnDoe | 6 | right | @JohnDoe |
//| Hello @JohnDoe! | JohnDoe | 14 | left | @JohnDoe |
function test_mention_is_selected_by_keyboard_data() {
return [
{tag: "SelectRightNoMention", textInput: "Hello @JohnDoe!", mention: "", initialPosition: 6, key: Qt.Key_Right, expectedSelection: "@"},
{tag: "SelectLeftNoMention", textInput: "Hello @JohnDoe!", mention: "", initialPosition: 14, key: Qt.Key_Left, expectedSelection: "e"},
{tag: "SelectRightWithMention", textInput: "Hello @JohnDoe!", mention: "JohnDoe", initialPosition: 6, key: Qt.Key_Right, expectedSelection: "@JohnDoe"},
{tag: "SelectLeftWithMention", textInput: "Hello @JohnDoe!", mention: "JohnDoe", initialPosition: 14, key: Qt.Key_Left, expectedSelection: "@JohnDoe"}
]
}
function test_mention_is_selected_by_keyboard(data) {
if(data.mention !== "") {
testHelper.when_the_user_has_contact(controlUnderTest, data.mention, (contact) => {})
}
testHelper.when_text_is_typed(statusChatInputMentions,
data.textInput, (typedText) => {})
controlUnderTest.textInput.cursorPosition = data.initialPosition
compare(controlUnderTest.textInput.cursorPosition, data.initialPosition)
keyClick(data.key, Qt.ShiftModifier)
compare(controlUnderTest.textInput.selectedText, data.expectedSelection)
}
//Scenario: Clicking mention will select the mention
//Given the user has contact JohnDoe
//And has typed message Hello @JohnDoe!
//When the user clicks @JohnDoe text
//Then the text @JohnDoe is selected
function test_mention_click_is_selecting_mention() {
testHelper.when_the_user_has_contact(controlUnderTest, "JohnDoe", (contact) => {})
testHelper.when_text_is_typed(statusChatInputMentions,
"Hello @JohnDoe!", (typedText) => {})
controlUnderTest.textInput.cursorPosition = 6
const cursorRectangle = controlUnderTest.textInput.cursorRectangle
mouseClick(controlUnderTest.textInput, cursorRectangle.x + 5, controlUnderTest.textInput.height / 2)
compare(controlUnderTest.textInput.selectedText,
"@JohnDoe")
}
//Scenario: Mention cannot be invalidated by user actions
//Given the user has contact JohnDoe
//And has typed message Hello @JohnDoe!
//When the user is performing <actions>
//And hits enter
//Then the mention is still valid
//And can be replaced with publicKey
//Example:
//|Action|
//|The space after mention is deleted and mention suggestion is closed key left|
//|The space after mention is deleted and mention suggestion is closed key "S"|
function test_mention_cannot_be_invalidated() {
testHelper.when_the_user_has_contact(controlUnderTest, "JohnDoe", (contact) => {})
testHelper.when_text_is_typed(statusChatInputMentions,
"Hello @JohnDoe!", (typedText) => {})
controlUnderTest.textInput.cursorPosition = 15
keyClick(Qt.Key_Backspace)
compare(controlUnderTest.textInput.getText(0, controlUnderTest.textInput.length), "Hello @JohnDoe!")
keyClick(Qt.Key_Left)
compare(controlUnderTest.textInput.getText(0, controlUnderTest.textInput.length), "Hello @JohnDoe !")
var plainTextWithPubKey = TextUtils.htmlToPlainText(controlUnderTest.getTextWithPublicKeys())
compare(plainTextWithPubKey, "Hello @0x0JohnDoe !")
controlUnderTest.textInput.cursorPosition = 15
keyClick(Qt.Key_Backspace)
compare(controlUnderTest.textInput.getText(0, controlUnderTest.textInput.length), "Hello @JohnDoe!")
keyClick(Qt.Key_S)
plainTextWithPubKey = TextUtils.htmlToPlainText(controlUnderTest.getTextWithPublicKeys())
compare(plainTextWithPubKey, "Hello @0x0JohnDoe s!")
}
//Scenario: User can remove mention by replacing a larger selected text section with a letter
//Given the user has contact JohnDoe
//And has typed "Hello @JohnDoe!"
//And has selected "lo @JohnDoe !"
//And has typed "s"
//Then the text is "Hells"
//And the mention is removed
function test_mention_is_deleted_with_large_selection() {
testHelper.when_the_user_has_contact(controlUnderTest, "JohnDoe", (contact) => {})
testHelper.when_text_is_typed(statusChatInputMentions,
"Hello @JohnDoe!", (typedText) => {})
controlUnderTest.textInput.select(3, 16)
compare(controlUnderTest.textInput.selectedText,
"lo @JohnDoe !")
keyClick(Qt.Key_S)
compare(controlUnderTest.textInput.getText(0, controlUnderTest.textInput.length),
"Hels")
const plainTextWithPubKey = TextUtils.htmlToPlainText(controlUnderTest.getTextWithPublicKeys())
compare(plainTextWithPubKey,
"Hels")
}
}
QtObject { QtObject {
id: testHelper id: testHelper

View File

@ -216,6 +216,51 @@ Rectangle {
mentionsPos[k].rightIndex = aliasIndex + mentionsPos[k].name.length 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)
} }
} }
@ -278,27 +323,6 @@ Rectangle {
property var mentionsPos: [] property var mentionsPos: []
function insertMention(aliasName, publicKey, lastAtPosition, lastCursorPosition) {
let startInd = aliasName.indexOf("(");
if (startInd > 0){
aliasName = aliasName.substring(0, startInd-1)
}
const hasEmoji = StatusQUtils.Emoji.hasEmoji(messageInputField.text)
const spanPlusAlias = `${Constants.mentionSpanTag}@${aliasName}</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.push({name: aliasName, pubKey: publicKey, leftIndex: lastAtPosition, rightIndex: (lastAtPosition+aliasName.length + 1)});
d.sortMentions()
}
function isUploadFilePressed(event) { function isUploadFilePressed(event) {
return (event.key === Qt.Key_U) && (event.modifiers & Qt.ControlModifier) && imageBtn.visible && !imageDialog.visible return (event.key === Qt.Key_U) && (event.modifiers & Qt.ControlModifier) && imageBtn.visible && !imageDialog.visible
} }
@ -377,8 +401,21 @@ Rectangle {
} }
event.accepted = true event.accepted = true
} }
// handle backspace when entering an existing blockquote
if ((event.key === Qt.Key_Backspace || event.key === Qt.Key_Delete)) { 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")) { if(message.data.startsWith(">") && message.data.endsWith("\n\n")) {
const newMessage = message.data.substr(0, message.data.lastIndexOf("\n")) + "> "; const newMessage = message.data.substr(0, message.data.lastIndexOf("\n")) + "> ";
messageInputField.remove(0, messageInputField.cursorPosition); messageInputField.remove(0, messageInputField.cursorPosition);
@ -446,7 +483,7 @@ Rectangle {
let suggestionItem = suggestionsBox.suggestionsModel.get(suggestionsBox.listView.currentIndex); let suggestionItem = suggestionsBox.suggestionsModel.get(suggestionsBox.listView.currentIndex);
if (aliasName.toLowerCase() === suggestionItem.name.toLowerCase() if (aliasName.toLowerCase() === suggestionItem.name.toLowerCase()
&& (event.key !== Qt.Key_Backspace) && (event.key !== Qt.Key_Delete)) { && (event.key !== Qt.Key_Backspace) && (event.key !== Qt.Key_Delete)) {
insertMention(aliasName, suggestionItem.publicKey, lastAtPosition, lastCursorPosition); d.insertMention(aliasName, suggestionItem.publicKey, lastAtPosition, lastCursorPosition);
} else if (event.key === Qt.Key_Space) { } else if (event.key === Qt.Key_Space) {
var plainTextToReplace = messageInputField.getText(lastAtPosition, lastCursorPosition); var plainTextToReplace = messageInputField.getText(lastAtPosition, lastCursorPosition);
messageInputField.remove(lastAtPosition, lastCursorPosition); messageInputField.remove(lastAtPosition, lastCursorPosition);
@ -627,7 +664,9 @@ Rectangle {
} }
if(d.rightOfMentionIndex !== -1) { if(d.rightOfMentionIndex !== -1) {
messageInputField.insert(mentionsPos[d.rightOfMentionIndex].rightIndex, eventText) //make sure to add an extra space between mention and text
let mentionSeparator = event.key === Qt.Key_Space ? "" : "&nbsp;"
messageInputField.insert(mentionsPos[d.rightOfMentionIndex].rightIndex, mentionSeparator + eventText)
d.rightOfMentionIndex = -1 d.rightOfMentionIndex = -1
} }
@ -946,7 +985,7 @@ Rectangle {
onItemSelected: function (item, lastAtPosition, lastCursorPosition) { onItemSelected: function (item, lastAtPosition, lastCursorPosition) {
messageInputField.forceActiveFocus(); messageInputField.forceActiveFocus();
let name = item.name.replace("@", "") let name = item.name.replace("@", "")
insertMention(name, item.publicKey, lastAtPosition, lastCursorPosition) d.insertMention(name, item.publicKey, lastAtPosition, lastCursorPosition)
suggestionsBox.suggestionsModel.clear() suggestionsBox.suggestionsModel.clear()
} }
onVisibleChanged: { onVisibleChanged: {
@ -1190,6 +1229,7 @@ Rectangle {
property var lastClick: 0 property var lastClick: 0
property int cursorWhenPressed: 0 property int cursorWhenPressed: 0
property int previousCursorPosition: 0
width: inputScrollView.availableWidth width: inputScrollView.availableWidth
@ -1206,17 +1246,6 @@ Rectangle {
leftPadding: 0 leftPadding: 0
padding: 0 padding: 0
Keys.onPressed: { Keys.onPressed: {
if (mentionsPos.length > 0) {
for (var i = 0; i < mentionsPos.length; i++) {
if ((messageInputField.cursorPosition === (mentionsPos[i].leftIndex))
&& (event.key === Qt.Key_Delete)) {
messageInputField.remove(mentionsPos[i].rightIndex, mentionsPos[i].leftIndex)
mentionsPos.pop(i)
d.sortMentions()
}
}
}
keyEvent = event; keyEvent = event;
onKeyPress(event) onKeyPress(event)
cursorWhenPressed = cursorPosition; cursorWhenPressed = cursorPosition;
@ -1233,36 +1262,22 @@ Rectangle {
} }
onCursorPositionChanged: { onCursorPositionChanged: {
if (mentionsPos.length > 0) { if(mentionsPos.length > 0) {
for (var i = 0; i < mentionsPos.length; i++) { const mention = d.getMentionAtPosition(cursorPosition)
if ((messageInputField.cursorPosition === (mentionsPos[i].leftIndex + 1)) && (keyEvent.key === Qt.Key_Right)) { if(mention) {
messageInputField.cursorPosition = mentionsPos[i].rightIndex; const cursorMovingLeft = cursorPosition < previousCursorPosition
} else if (messageInputField.cursorPosition === (mentionsPos[i].rightIndex - 1)) { const newCursorPosition = cursorMovingLeft ?
if (keyEvent.key === Qt.Key_Left) { mention.leftIndex :
messageInputField.cursorPosition = mentionsPos[i].leftIndex; mention.rightIndex
} else if ((keyEvent.key === Qt.Key_Backspace) || (keyEvent.key === Qt.Key_Delete)) { const isSelection = selectionStart != selectionEnd
messageInputField.remove(mentionsPos[i].rightIndex, mentionsPos[i].leftIndex); isSelection ? moveCursorSelection(newCursorPosition, TextEdit.SelectCharacters) :
mentionsPos.pop(i); cursorPosition = newCursorPosition
d.sortMentions()
}
} else if (((messageInputField.cursorPosition > mentionsPos[i].leftIndex) &&
(messageInputField.cursorPosition < mentionsPos[i].rightIndex)) &&
((keyEvent.key === Qt.Key_Left) && ((keyEvent.modifiers & Qt.AltModifier) ||
(keyEvent.modifiers & Qt.ControlModifier)))) {
messageInputField.cursorPosition = mentionsPos[i].leftIndex;
} else if ((keyEvent.key === Qt.Key_Up) || (keyEvent.key === Qt.Key_Down)) {
if (messageInputField.cursorPosition >= mentionsPos[i].leftIndex &&
messageInputField.cursorPosition <= (((mentionsPos[i].leftIndex + mentionsPos[i].rightIndex)/2))) {
messageInputField.cursorPosition = mentionsPos[i].leftIndex;
} else if (messageInputField.cursorPosition <= mentionsPos[i].rightIndex &&
messageInputField.cursorPosition > (((mentionsPos[i].leftIndex + mentionsPos[i].rightIndex)/2))) {
messageInputField.cursorPosition = mentionsPos[i].rightIndex;
}
}
} }
} }
inputScrollView.ensureVisible(cursorRectangle) inputScrollView.ensureVisible(cursorRectangle)
previousCursorPosition = cursorPosition
} }
onTextChanged: { onTextChanged: {
@ -1287,6 +1302,7 @@ Rectangle {
} }
d.updateMentionsPositions() d.updateMentionsPositions()
d.cleanMentionsPos()
messageLengthLimit.remainingChars = (messageLimit - length); messageLengthLimit.remainingChars = (messageLimit - length);
} }
@ -1306,6 +1322,13 @@ Rectangle {
lastClick = now lastClick = now
} }
onLinkActivated: {
const mention = d.getMentionAtPosition(cursorPosition - 1)
if(mention) {
select(mention.leftIndex, mention.rightIndex)
}
}
cursorDelegate: Rectangle { cursorDelegate: Rectangle {
color: Theme.palette.primaryColor1 color: Theme.palette.primaryColor1
implicitWidth: 2 implicitWidth: 2