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:
parent
94d7c478eb
commit
6cba8810e0
|
@ -12,6 +12,8 @@ import shared.stores 1.0
|
|||
SplitView {
|
||||
id: root
|
||||
|
||||
Logs { id: logs }
|
||||
|
||||
QtObject {
|
||||
id: globalUtilsMock
|
||||
|
||||
|
@ -90,8 +92,22 @@ SplitView {
|
|||
usersStore: QtObject {
|
||||
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 {
|
||||
|
|
|
@ -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 {
|
||||
id: testHelper
|
||||
|
||||
|
|
|
@ -216,6 +216,51 @@ Rectangle {
|
|||
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: []
|
||||
|
||||
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) {
|
||||
return (event.key === Qt.Key_U) && (event.modifiers & Qt.ControlModifier) && imageBtn.visible && !imageDialog.visible
|
||||
}
|
||||
|
@ -377,8 +401,21 @@ Rectangle {
|
|||
}
|
||||
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")) {
|
||||
const newMessage = message.data.substr(0, message.data.lastIndexOf("\n")) + "> ";
|
||||
messageInputField.remove(0, messageInputField.cursorPosition);
|
||||
|
@ -446,7 +483,7 @@ Rectangle {
|
|||
let suggestionItem = suggestionsBox.suggestionsModel.get(suggestionsBox.listView.currentIndex);
|
||||
if (aliasName.toLowerCase() === suggestionItem.name.toLowerCase()
|
||||
&& (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) {
|
||||
var plainTextToReplace = messageInputField.getText(lastAtPosition, lastCursorPosition);
|
||||
messageInputField.remove(lastAtPosition, lastCursorPosition);
|
||||
|
@ -627,7 +664,9 @@ Rectangle {
|
|||
}
|
||||
|
||||
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 ? "" : " "
|
||||
messageInputField.insert(mentionsPos[d.rightOfMentionIndex].rightIndex, mentionSeparator + eventText)
|
||||
|
||||
d.rightOfMentionIndex = -1
|
||||
}
|
||||
|
@ -946,7 +985,7 @@ Rectangle {
|
|||
onItemSelected: function (item, lastAtPosition, lastCursorPosition) {
|
||||
messageInputField.forceActiveFocus();
|
||||
let name = item.name.replace("@", "")
|
||||
insertMention(name, item.publicKey, lastAtPosition, lastCursorPosition)
|
||||
d.insertMention(name, item.publicKey, lastAtPosition, lastCursorPosition)
|
||||
suggestionsBox.suggestionsModel.clear()
|
||||
}
|
||||
onVisibleChanged: {
|
||||
|
@ -1190,6 +1229,7 @@ Rectangle {
|
|||
|
||||
property var lastClick: 0
|
||||
property int cursorWhenPressed: 0
|
||||
property int previousCursorPosition: 0
|
||||
|
||||
width: inputScrollView.availableWidth
|
||||
|
||||
|
@ -1206,17 +1246,6 @@ Rectangle {
|
|||
leftPadding: 0
|
||||
padding: 0
|
||||
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;
|
||||
onKeyPress(event)
|
||||
cursorWhenPressed = cursorPosition;
|
||||
|
@ -1233,36 +1262,22 @@ Rectangle {
|
|||
}
|
||||
|
||||
onCursorPositionChanged: {
|
||||
if (mentionsPos.length > 0) {
|
||||
for (var i = 0; i < mentionsPos.length; i++) {
|
||||
if ((messageInputField.cursorPosition === (mentionsPos[i].leftIndex + 1)) && (keyEvent.key === Qt.Key_Right)) {
|
||||
messageInputField.cursorPosition = mentionsPos[i].rightIndex;
|
||||
} else if (messageInputField.cursorPosition === (mentionsPos[i].rightIndex - 1)) {
|
||||
if (keyEvent.key === Qt.Key_Left) {
|
||||
messageInputField.cursorPosition = mentionsPos[i].leftIndex;
|
||||
} else if ((keyEvent.key === Qt.Key_Backspace) || (keyEvent.key === Qt.Key_Delete)) {
|
||||
messageInputField.remove(mentionsPos[i].rightIndex, mentionsPos[i].leftIndex);
|
||||
mentionsPos.pop(i);
|
||||
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;
|
||||
}
|
||||
}
|
||||
if(mentionsPos.length > 0) {
|
||||
const mention = d.getMentionAtPosition(cursorPosition)
|
||||
if(mention) {
|
||||
const cursorMovingLeft = cursorPosition < previousCursorPosition
|
||||
const newCursorPosition = cursorMovingLeft ?
|
||||
mention.leftIndex :
|
||||
mention.rightIndex
|
||||
const isSelection = selectionStart != selectionEnd
|
||||
isSelection ? moveCursorSelection(newCursorPosition, TextEdit.SelectCharacters) :
|
||||
cursorPosition = newCursorPosition
|
||||
}
|
||||
}
|
||||
|
||||
inputScrollView.ensureVisible(cursorRectangle)
|
||||
|
||||
previousCursorPosition = cursorPosition
|
||||
}
|
||||
|
||||
onTextChanged: {
|
||||
|
@ -1287,6 +1302,7 @@ Rectangle {
|
|||
}
|
||||
|
||||
d.updateMentionsPositions()
|
||||
d.cleanMentionsPos()
|
||||
|
||||
messageLengthLimit.remainingChars = (messageLimit - length);
|
||||
}
|
||||
|
@ -1306,6 +1322,13 @@ Rectangle {
|
|||
lastClick = now
|
||||
}
|
||||
|
||||
onLinkActivated: {
|
||||
const mention = d.getMentionAtPosition(cursorPosition - 1)
|
||||
if(mention) {
|
||||
select(mention.leftIndex, mention.rightIndex)
|
||||
}
|
||||
}
|
||||
|
||||
cursorDelegate: Rectangle {
|
||||
color: Theme.palette.primaryColor1
|
||||
implicitWidth: 2
|
||||
|
|
Loading…
Reference in New Issue