feat: Keyboard shortcuts

Add keyboard shortcuts according to https://notes.status.im/02cfVf1KQLeQU2SqrIi9tw

fix: update chat message bubbles
- Align emojis to middle of text
- Add line-height as per design
- Properly support RTL languages (right-aligned) and LTR languages (left-aligned)
- Remove unneeded non-breaking space at the beginning of current user messages
- Properly support markdown for bold, strikethrough, and italic
- Fix text being removed when in between strikethrough markdown (~~)

fix: emoji resolution update for high resolution monitors
- Emojis now use the 72x72 original set, but are down-scaled to 20x20 (in chat bubbles) or 22x22 in other places, effectively tripling their pixel density

feat: handle new lines in blockquote

Handle new lines in blockquote so that messages display correctly.

Also, add functionality when a new line is entered in to the chat input, if it's inside a blockquote, a new ">" will be added automatically. This is also handled when backspace is entered.

feat: update xss to support full qt html4 table and table-cell attributes
This commit is contained in:
emizzle 2020-11-17 14:07:01 +11:00 committed by Iuri Matias
parent e2ec5fa84e
commit 417194e7b4
18 changed files with 211 additions and 65 deletions

View File

@ -1,4 +1,6 @@
import sequtils
import sequtils, re
let NEW_LINE = re"\n|\r"
proc sectionIdentifier(message: Message): string =
result = message.fromAuthor
@ -15,15 +17,16 @@ proc mention(self: ChatMessageList, pubKey: string): string =
# See render-inline in status-react/src/status_im/ui/screens/chat/message/message.cljs
proc renderInline(self: ChatMessageList, elem: TextItem): string =
let value = escape_html(elem.literal.strip)
let value = escape_html(elem.literal)
case elem.textType:
of "": result = value
of "code": result = fmt(" <code>{value}</code> ")
of "emph": result = fmt(" <em>{value}</em> ")
of "strong": result = fmt(" <strong>{value}</strong> ")
of "link": result = fmt(" {elem.destination} ")
of "mention": result = fmt(" <a href=\"//{value}\" class=\"mention\">{self.mention(value)}</a> ")
of "status-tag": result = fmt(" <a href=\"#{value}\" class=\"status-tag\">#{value}</a> ")
of "code": result = fmt("<code>{value}</code>")
of "emph": result = fmt("<em>{value}</em>")
of "strong": result = fmt("<strong>{value}</strong>")
of "link": result = fmt("{elem.destination}")
of "mention": result = fmt("<a href=\"//{value}\" class=\"mention\">{self.mention(value)}</a>")
of "status-tag": result = fmt("<a href=\"#{value}\" class=\"status-tag\">#{value}</a>")
of "del": result = fmt("<del>{value}</del>")
# See render-block in status-react/src/status_im/ui/screens/chat/message/message.cljs
proc renderBlock(self: ChatMessageList, message: Message): string =
@ -31,14 +34,26 @@ proc renderBlock(self: ChatMessageList, message: Message): string =
case pMsg.textType:
of "paragraph":
result = result & "<p>"
if message.isCurrentUser:
result = result & "&nbsp;"
for children in pMsg.children:
result = result & self.renderInline(children)
result = result & "</p>"
of "blockquote":
result = result & pMsg.literal.strip.split("\n").mapIt("<span>▍ " & escape_html(it) & "</span>").join("<br />")
var
blockquote = escape_html(pMsg.literal)
lines = toSeq(blockquote.split(NEW_LINE))
for i in 0..(lines.len - 1):
if i + 1 >= lines.len:
continue
if lines[i + 1] != "":
lines[i] = lines[i] & "<br/>"
blockquote = lines.join("")
result = result & fmt(
"<table class=\"blockquote\">" &
"<tr>" &
"<td class=\"quoteline\" valign=\"middle\"></td>" &
"<td>{blockquote}</td>" &
"</tr>" &
"</table>")
of "codeblock":
result = result & "<code>" & escape_html(pMsg.literal.strip) & "</code>"
result = result & "<code>" & escape_html(pMsg.literal) & "</code>"
result = result.strip()

View File

@ -47,7 +47,7 @@ Loader {
visible: repliedMessageType != Constants.imageType
anchors.top: lblReplyAuthor.bottom
anchors.topMargin: 5
text: Emoji.parse(Utils.linkifyAndXSS(repliedMessageContent), "26x26");
text: Emoji.parse(Utils.linkifyAndXSS(repliedMessageContent));
textFormat: Text.RichText
color: root.elementsColor
readOnly: true

View File

@ -40,7 +40,6 @@ Item {
StyledTextEdit {
id: chatText
textFormat: Text.RichText
horizontalAlignment: Text.AlignLeft
wrapMode: Text.Wrap
font.pixelSize: Style.current.primaryTextFontSize
readOnly: true
@ -66,35 +65,41 @@ Item {
if(contentType === Constants.stickerType) return "";
let msg = Utils.linkifyAndXSS(message);
if(isEmoji) {
return Emoji.parse(msg, "72x72");
return Emoji.parse(msg, Emoji.size.big);
} else {
return `<html>`+
`<head>`+
`<style type="text/css">`+
`code {`+
`background-color: #1a356b;`+
`color: #FFFFFF;`+
`white-space: pre;`+
`}`+
`p {`+
`white-space: pre-wrap;`+
`}`+
`a {`+
`color: ${isCurrentUser && !appSettings.compactMode ? Style.current.white : Style.current.textColor};`+
`}`+
`a.mention {`+
`color: ${isCurrentUser ? Style.current.cyan : Style.current.turquoise};`+
`}`+
`blockquote {`+
`margin: 0;`+
`padding: 0;`+
`}`+
`</style>`+
`</head>`+
`<body>`+
`${Emoji.parse(msg, "26x26")}`+
`</body>`+
`</html>`;
return `<style type="text/css">` +
`p, img, a, del, code, blockquote { margin: 0; padding: 0; }` +
`code {` +
`background-color: ${Style.current.codeBackground};` +
`color: ${Style.current.white};` +
`white-space: pre;` +
`}` +
`p {` +
`line-height: 22px;` +
`}` +
`a {` +
`color: ${isCurrentUser && !appSettings.compactMode ? Style.current.white : Style.current.textColor};` +
`}` +
`a.mention {` +
`color: ${isCurrentUser ? Style.current.cyan : Style.current.turquoise};` +
`}` +
`del {` +
`text-decoration: line-through;` +
`}` +
`table.blockquote td {` +
`padding-left: 10px;` +
`color: ${isCurrentUser ? Style.current.chatReplyCurrentUser : Style.current.secondaryText};` +
`}` +
`table.blockquote td.quoteline {` +
`background-color: ${isCurrentUser ? Style.current.chatReplyCurrentUser : Style.current.secondaryText};` +
`height: 100%;` +
`padding-left: 0;` +
`}` +
`.emoji {` +
`vertical-align: bottom;` +
`}` +
`</style>` +
`${Emoji.parse(msg)}`
}
}
}

View File

@ -39,7 +39,7 @@ Item {
readonly property int defaultMaxMessageChars: 54
readonly property int messageWidth: Math.max(defaultMessageWidth, parent.width / 2)
readonly property int maxMessageChars: (defaultMaxMessageChars * messageWidth) / defaultMessageWidth
property int chatVerticalPadding: isImage ? 4 : 7
property int chatVerticalPadding: isImage ? 4 : 6
property int chatHorizontalPadding: isImage ? 0 : 12
property bool longReply: chatReply.visible && repliedMessageContent.length > maxMessageChars
property bool longChatText: chatsModel.plainText(message).split('\n').some(function (messagePart) {
@ -125,7 +125,6 @@ Item {
anchors.leftMargin: chatBox.chatHorizontalPadding
anchors.right: chatBox.longChatText ? parent.right : undefined
anchors.rightMargin: chatBox.longChatText ? chatBox.chatHorizontalPadding : 0
textField.horizontalAlignment: !isCurrentUser ? Text.AlignLeft : Text.AlignRight
textField.color: !isCurrentUser ? Style.current.textColor : Style.current.currentUserTextColor
}

View File

@ -65,7 +65,7 @@ Rectangle {
StyledText {
id: contactInfo
text: wrapper.chatType !== Constants.chatTypePublic ?
Emoji.parse(Utils.removeStatusEns(Utils.filterXSS(wrapper.name)), "26x26") :
Emoji.parse(Utils.removeStatusEns(Utils.filterXSS(wrapper.name))) :
"#" + Utils.filterXSS(wrapper.name)
anchors.right: contactTime.left
anchors.rightMargin: Style.current.smallPadding
@ -89,7 +89,7 @@ Rectangle {
//% "Sticker"
case Constants.stickerType: return qsTrId("sticker");
//% "No messages"
default: return lastMessage ? Emoji.parse(Utils.filterXSS(lastMessage), "26x26").replace(/\n|\r/g, ' ') : qsTrId("no-messages")
default: return lastMessage ? Emoji.parse(Utils.filterXSS(lastMessage)).replace(/\n|\r/g, ' ') : qsTrId("no-messages")
}
}
textFormat: Text.RichText

View File

@ -39,6 +39,23 @@ RowLayout {
}
}
Action {
shortcut: "Ctrl+1"
onTriggered: changeAppSection(Constants.chat)
}
Action {
shortcut: "Ctrl+2"
onTriggered: changeAppSection(Constants.browser)
}
Action {
shortcut: "Ctrl+3"
onTriggered: changeAppSection(Constants.wallet)
}
Action {
shortcut: "Ctrl+4, Ctrl+,"
onTriggered: changeAppSection(Constants.profile)
}
function changeAppSection(section) {
let sectionId = -1
switch (section) {

View File

@ -5,28 +5,38 @@ import "./twemoji/twemoji.js" as Twemoji
import "../shared/status/emojiList.js" as EmojiJSON
QtObject {
readonly property var size: {
"big": "72x72",
"small": "20x20"
}
property string base: Qt.resolvedUrl("twemoji/")
function parse(text, size) {
function parse(text, renderSize = size.small) {
const renderSizes = renderSize.split("x");
if (!renderSize.includes("x") || renderSizes.length !== 2) {
throw new Error("Invalid value for 'renderSize' parameter: ", renderSize);
}
Twemoji.twemoji.base = base
Twemoji.twemoji.ext = ".png"
Twemoji.twemoji.size = size
return Twemoji.twemoji.parse(text)
Twemoji.twemoji.size = size.big // source size in filesystem - get 72x72 and downscale for increased pixel density
return Twemoji.twemoji.parse(text, {
attributes: function() { return { width: renderSizes[0], height: renderSizes[1] }}
})
}
function fromCodePoint(value) {
return Twemoji.twemoji.convert.fromCodePoint(value)
}
function deparse(value){
return value.replace(/<img src=\"qrc:\/imports\/twemoji\/.+?" alt=\"(.+?)\" \/>/g, "$1");
return value.replace(/<img src=\"qrc:\/imports\/twemoji\/.+?" alt=\"(.+?)\" width=\"[0-9]*\" height=\"[0-9]*\" \/>/g, "$1");
}
function deparseFromParse(value) {
return value.replace(/<img class=\"emoji\" draggable=\"false\" alt=\"(.+?)\" src=\"qrc:\/imports\/twemoji\/.+?"\/>/g, "$1");
return value.replace(/<img class=\"emoji\" draggable=\"false\" alt=\"(.+?)\" src=\"qrc:\/imports\/twemoji\/.+?" width=\"[0-9]*\" height=\"[0-9]*\"\/>/g, "$1");
}
function hasEmoji(value) {
let match = value.match(/<img src=\"qrc:\/imports\/twemoji\/.+?" alt=\"(.+?)\" \/>/g)
let match = value.match(/<img src=\"qrc:\/imports\/twemoji\/.+?" alt=\"(.+?)\" width=\"[0-9]*\" height=\"[0-9]*\"\ \/>/g)
return match && match.length > 0
}
function getEmojis(value) {
return value.match(/<img class=\"emoji\" draggable=\"false\" alt=\"(.+?)\" src=\"qrc:\/imports\/twemoji\/.+?"\/>/g, "$1");
return value.match(/<img class=\"emoji\" draggable=\"false\" alt=\"(.+?)\" src=\"qrc:\/imports\/twemoji\/.+?" width=\"[0-9]*\" height=\"[0-9]*\"\/>/g, "$1");
}
function getEmojiUnicode(shortname) {
var _emoji;

View File

@ -49,6 +49,7 @@ Theme {
property color pillButtonTextColor: almostBlack
property color chatReplyCurrentUser: evenDarkerGrey
property color topBarChatInfoColor: evenDarkerGrey
property color codeBackground: "#2E386B"
property color buttonForegroundColor: blue
property color buttonBackgroundColor: secondaryBackground

View File

@ -48,6 +48,7 @@ Theme {
property color pillButtonTextColor: white
property color chatReplyCurrentUser: lighterDarkGrey
property color topBarChatInfoColor: grey
property color codeBackground: "#2E386B"
property color buttonForegroundColor: blue
property color buttonBackgroundColor: secondaryBackground

View File

@ -35,6 +35,7 @@ QtObject {
property color currentUserTextColor
property color secondaryBackground
property color modalBackground
property color codeBackground
property color buttonForegroundColor
property color buttonBackgroundColor

View File

@ -37,6 +37,28 @@ ApplicationWindow {
}
visible: true
Action {
shortcut: StandardKey.FullScreen
onTriggered: {
if (visibility === Window.FullScreen) {
showNormal()
} else {
showFullScreen()
}
}
}
Action {
shortcut: "Ctrl+M"
onTriggered: {
if (visibility === Window.Minimized) {
showNormal()
} else {
showMinimized()
}
}
}
Component.onCompleted: {
// Change the theme to the system theme (dark/light) until we get the
// user's saved setting from status-go (after login)

View File

@ -79,6 +79,10 @@ Rectangle {
return msg
}
function isUploadFilePressed(event) {
return (event.key === Qt.Key_U) && (event.modifiers & Qt.ControlModifier) && imageBtn.visible && !imageDialog.visible
}
function onKeyPress(event){
if (event.modifiers === Qt.NoModifier && (event.key === Qt.Key_Enter || event.key === Qt.Key_Return)) {
if (emojiSuggestions.visible) {
@ -99,10 +103,42 @@ Rectangle {
messageTooLongDialog.open()
}
// handle new line in blockquote
if ((event.key === Qt.Key_Enter || event.key === Qt.Key_Return) && (event.modifiers & Qt.ShiftModifier)) {
const message = control.extrapolateCursorPosition();
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)) {
const message = control.extrapolateCursorPosition();
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()
@ -118,6 +154,12 @@ Rectangle {
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()
}
function onRelease(event) {
// the text doesn't get registered to the textarea fast enough
@ -160,7 +202,7 @@ Rectangle {
if (madeChanges) {
messageInputField.remove(0, messageInputField.length);
insertInTextInput(0, Emoji.parse(words.join('&nbsp;'), '26x26'));
insertInTextInput(0, Emoji.parse(words.join('&nbsp;')));
}
}
@ -169,7 +211,7 @@ Rectangle {
function extrapolateCursorPosition() {
// we need only the message part to be html
const text = chatsModel.plainText(Emoji.deparse(messageInputField.text));
const plainText = Emoji.parse(text, '26x26');
const plainText = Emoji.parse(text);
var bracketEvent = false;
var length = 0;
@ -258,7 +300,7 @@ Rectangle {
.replace(shortname, encodedCodePoint)
.replace(/ /g, "&nbsp;");
messageInputField.remove(0, messageInputField.cursorPosition);
insertInTextInput(0, Emoji.parse(newMessage, '26x26'));
insertInTextInput(0, Emoji.parse(newMessage));
emojiSuggestions.close()
emojiEvent = false
}
@ -391,7 +433,7 @@ Rectangle {
text = `${left} @${aliasName} ${right}`
}
messageInputField.text = hasEmoji ? Emoji.parse(text, "26x26") : text
messageInputField.text = hasEmoji ? Emoji.parse(text) : text
messageInputField.cursorPosition = lastAtPosition + aliasName.length + 2
suggestionsBox.suggestionsModel.clear()
}
@ -592,11 +634,40 @@ Rectangle {
bottomPadding: 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"
}
}
Action {
shortcut: StandardKey.Bold
onTriggered: wrapSelection("**")
}
Action {
shortcut: StandardKey.Italic
onTriggered: wrapSelection("*")
}
Action {
shortcut: "Ctrl+Shift+Alt+C"
onTriggered: wrapSelection("```")
}
Action {
shortcut: "Ctrl+Shift+C"
onTriggered: wrapSelection("`")
}
Action {
shortcut: "Ctrl+Alt+-"
onTriggered: wrapSelection("~~")
}
Action {
shortcut: "Ctrl+Shift+X"
onTriggered: wrapSelection("~~")
}
Action {
shortcut: "Ctrl+Meta+Space"
onTriggered: emojiBtn.clicked()
}
}
Rectangle {

View File

@ -40,7 +40,7 @@ Rectangle {
StyledText {
id: replyText
text: Emoji.parse(message, "26x26")
text: Emoji.parse(message)
anchors.left: replyToUsername.left
anchors.top: replyToUsername.bottom
anchors.topMargin: 2

View File

@ -63,7 +63,7 @@ Popup {
emojiSectionsRepeater.itemAt(0).allEmojis = recentEmojis
appSettings.recentEmojis = recentEmojis
popup.emojiSelected(Emoji.parse(encodedIcon, "26x26") + ' ', true) // Adding a space because otherwise, some emojis would fuse since emoji is just a string
popup.emojiSelected(Emoji.parse(encodedIcon) + ' ', true) // Adding a space because otherwise, some emojis would fuse since emoji is just a string
popup.close()
}
@ -135,7 +135,7 @@ Popup {
anchors.verticalCenter: searchBox.verticalCenter
anchors.right: parent.right
anchors.rightMargin: emojiHeader.headerMargin
source: "../../../../imports/twemoji/26x26/1f590.png"
source: "../../../../imports/twemoji/72x72/1f590.png"
MouseArea {
cursorShape: Qt.PointingHandCursor

View File

@ -90,7 +90,7 @@ Item {
SVGImage {
width: emojiSection.imageWidth
height: emojiSection.imageWidth
source: "../../imports/twemoji/26x26/" + modelData.filename
source: "../../imports/twemoji/72x72/" + modelData.filename
MouseArea {
cursorShape: Qt.PointingHandCursor

View File

@ -10,7 +10,7 @@ StatusInputListPopup {
id: emojiSuggestions
getImageSource: function (modelData) {
return `../../imports/twemoji/26x26/${modelData.unicode}.png`
return `../../imports/twemoji/72x72/${modelData.unicode}.png`
}
getText: function (modelData) {
return modelData.shortname

View File

@ -11,6 +11,8 @@ Popup {
property var getImageSource: function () {}
property var getText: function () {}
property var onClicked: function () {}
property int imageWidth: 22
property int imageHeight: 22
function openPopup(listParam) {
modelList = listParam
@ -62,6 +64,8 @@ Popup {
SVGImage {
id: image
source: popup.getImageSource(modelData)
width: popup.imageWidth
height: popup.imageHeight
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: Style.current.smallPadding

View File

@ -297,9 +297,9 @@ function getDefaultWhiteList() {
sub: [],
sup: [],
strong: [],
table: ["width", "border", "align", "valign"],
table: ["width", "height", "border", "bgcolor", "cellspacing", "cellpadding", "class"],
tbody: ["align", "valign"],
td: ["width", "rowspan", "colspan", "align", "valign"],
td: ["width", "bgcolor", "rowspan", "colspan", "align", "valign", "class"],
tfoot: ["align", "valign"],
th: ["width", "rowspan", "colspan", "align", "valign"],
thead: ["align", "valign"],