Chatbar Refactor

This is mostly a stylistic rework of the chatbar but there are some buxfixes / improvements in here as well. The aim was to make the chatbar so it's size was managed in the same manner as the timeline in both bubble and compactmode in the same manner as network/neochat!476.

The other features are:
- Replies, attachments and edits now look like they are inside the chatbar and use a similar styling to edits in message bubbles
- Replies and edits now part of the message so they arte part of the ScrollView and will scroll away when the text is long
- ~~The emoji picker is now a popup so it doesn't mess with the timeline layout when activated~~ (done in network/neochat!697)
- ~~Emoji dialog is now no longer required as the picker itself is a popup now~~ (no longer the case see above)
- The scrollbar now sits on the right of the chatbar actions rather than weirdly to the left
- The action icons will always stay in the same place even as the chatbar gets taller


Updated\
![2022-12-10_14-13-41.mkv](/uploads/ccbe14843834a19bf98ef0028e023eae/2022-12-10_14-13-41.mkv)

Scrollbar behaviour before
![chatbar_refactor_scrollbar_before](/uploads/2f3b91a79eb302ccf83dd35e51004e6a/chatbar_refactor_scrollbar_before.png)

Scrollbar behaviour after
![chatbar_refactor_scrollbar_after](/uploads/fcab044d8a4338ed9bcff6721b65e89c/chatbar_refactor_scrollbar_after.png)
This commit is contained in:
James Graham
2023-01-24 18:02:19 +00:00
parent 826760a55c
commit eee93e0f1f
5 changed files with 498 additions and 540 deletions

View File

@@ -5,205 +5,62 @@
import QtQuick 2.15
import QtQuick.Layouts 1.15
import QtQuick.Controls 2.15 as QQC2
import QtQuick.Window 2.15
import org.kde.kirigami 2.18 as Kirigami
import org.kde.neochat 1.0
QQC2.ToolBar {
id: chatBar
property alias inputFieldText: inputField.text
property alias textField: inputField
property alias cursorPosition: inputField.cursorPosition
QQC2.Control {
id: root
property alias textField: textField
property bool isReplying: currentRoom.chatBoxReplyId.length > 0
property bool isEditing: currentRoom.chatBoxEditId.length > 0
property bool replyPaneVisible: isReplying || isEditing
property NeoChatUser replyUser: currentRoom.chatBoxReplyUser ?? currentRoom.chatBoxEditUser
property bool attachmentPaneVisible: currentRoom.chatBoxAttachmentPath.length > 0
signal inputFieldForceActiveFocusTriggered()
signal messageSent()
onInputFieldForceActiveFocusTriggered: {
inputField.forceActiveFocus();
// set the cursor to the end of the text
inputField.cursorPosition = inputField.length;
}
property list<Kirigami.Action> actions : [
Kirigami.Action {
id: attachmentAction
position: QQC2.ToolBar.Footer
property bool isBusy: currentRoom && currentRoom.hasFileUploading
Kirigami.Theme.colorSet: Kirigami.Theme.View
// Matrix does not allow sending attachments in replies
visible: currentRoom.chatBoxReplyId.length === 0 && currentRoom.chatBoxAttachmentPath.length === 0
icon.name: "mail-attachment"
text: i18n("Attach an image or file")
displayHint: Kirigami.DisplayHint.IconOnly
// Using a custom background because some styles like Material
// or Fusion might have ugly colors for a TextArea placed inside
// of a toolbar. ToolBar is otherwise the closest QQC2 component
// to what we want because of the padding and spacing values.
background: Rectangle {
color: Kirigami.Theme.backgroundColor
}
contentItem: RowLayout {
spacing: chatBar.spacing
QQC2.ScrollView {
Layout.fillHeight: true
Layout.fillWidth: true
Layout.minimumHeight: inputField.implicitHeight
// lineSpacing is height+leading, so subtract leading once since leading only exists between lines.
Layout.maximumHeight: fontMetrics.lineSpacing * 8 - fontMetrics.leading
+ inputField.topPadding + inputField.bottomPadding
// HACK: Hide unnecessary horizontal scrollbar (https://bugreports.qt.io/browse/QTBUG-83890)
QQC2.ScrollBar.horizontal.policy: QQC2.ScrollBar.AlwaysOff
FontMetrics {
id: fontMetrics
font: inputField.font
}
QQC2.TextArea {
id: inputField
focus: true
/* Some QQC2 styles will have their own predefined backgrounds for TextAreas.
* Make sure there is no background since we are using the ToolBar background.
*
* This could cause a problem if the QQC2 style was designed around TextArea
* background colors being very different from the QPalette::Base color.
* Luckily, none of the Qt QQC2 styles do that and neither do KDE's QQC2 styles.
*/
background: MouseArea {
acceptedButtons: Qt.NoButton
cursorShape: Qt.IBeamCursor
z: 1
}
leftPadding: mirrored ? 0 : Kirigami.Units.largeSpacing
rightPadding: !mirrored ? 0 : Kirigami.Units.largeSpacing
topPadding: 0
bottomPadding: 0
placeholderText: readOnly ? i18n("This room is encrypted. Build libQuotient with encryption enabled to send encrypted messages.") : currentRoom.chatBoxEditId.length > 0 ? i18n("Edit Message") : currentRoom.usesEncryption ? i18n("Send an encrypted message…") : currentRoom.chatBoxAttachmentPath.length > 0 ? i18n("Set an attachment caption...") : i18n("Send a message…")
verticalAlignment: TextEdit.AlignVCenter
horizontalAlignment: TextEdit.AlignLeft
wrapMode: Text.Wrap
readOnly: currentRoom.usesEncryption && !Controller.encryptionSupported
Kirigami.Theme.colorSet: Kirigami.Theme.View
Kirigami.Theme.inherit: false
color: Kirigami.Theme.textColor
selectionColor: Kirigami.Theme.highlightColor
selectedTextColor: Kirigami.Theme.highlightedTextColor
hoverEnabled: !Kirigami.Settings.tabletMode
selectByMouse: !Kirigami.Settings.tabletMode
Keys.onEnterPressed: {
if (completionMenu.visible) {
completionMenu.complete()
} else if (event.modifiers & Qt.ShiftModifier) {
inputField.insert(cursorPosition, "\n")
} else {
chatBar.postMessage();
}
}
Keys.onReturnPressed: {
if (completionMenu.visible) {
completionMenu.complete()
} else if (event.modifiers & Qt.ShiftModifier) {
inputField.insert(cursorPosition, "\n")
} else {
chatBar.postMessage();
}
}
Keys.onTabPressed: {
if (completionMenu.visible) {
completionMenu.complete()
}
}
Keys.onPressed: {
if (event.key === Qt.Key_V && event.modifiers & Qt.ControlModifier) {
chatBar.pasteImage();
} else if (event.key === Qt.Key_Up && event.modifiers & Qt.ControlModifier) {
let replyEvent = messageEventModel.getLatestMessageFromIndex(0)
if (replyEvent && replyEvent["event_id"]) {
currentRoom.chatBoxReplyId = replyEvent["event_id"]
onTriggered: {
if (Clipboard.hasImage) {
attachDialog.open()
} else {
var fileDialog = openFileDialog.createObject(QQC2.ApplicationWindow.overlay)
fileDialog.chosen.connect((path) => {
if (!path) {
return;
}
} else if (event.key === Qt.Key_Up && inputField.text.length === 0) {
let editEvent = messageEventModel.getLastLocalUserMessageEventId()
if (editEvent) {
currentRoom.chatBoxEditId = editEvent["event_id"]
}
} else if (event.key === Qt.Key_Up && completionMenu.visible) {
completionMenu.decrementIndex()
} else if (event.key === Qt.Key_Down && completionMenu.visible) {
completionMenu.incrementIndex()
} else if (event.key === Qt.Key_Backspace && inputField.text.length <= 1) {
currentRoom.sendTypingNotification(false)
repeatTimer.stop()
}
}
Timer {
id: repeatTimer
interval: 5000
}
onTextChanged: {
if (!repeatTimer.running && Config.typingNotifications) {
var textExists = text.length > 0
currentRoom.sendTypingNotification(textExists)
textExists ? repeatTimer.start() : repeatTimer.stop()
}
currentRoom.chatBoxText = text
currentRoom.chatBoxAttachmentPath = path;
})
fileDialog.open()
}
}
}
Item {
visible: currentRoom.chatBoxReplyId.length === 0 && (currentRoom.chatBoxAttachmentPath.length === 0 || uploadingBusySpinner.running)
implicitWidth: uploadButton.implicitWidth
implicitHeight: uploadButton.implicitHeight
QQC2.ToolButton {
id: uploadButton
anchors.fill: parent
// Matrix does not allow sending attachments in replies
visible: currentRoom.chatBoxReplyId.length === 0 && currentRoom.chatBoxAttachmentPath.length === 0 && !uploadingBusySpinner.running
icon.name: "mail-attachment"
text: i18n("Attach an image or file")
display: QQC2.AbstractButton.IconOnly
tooltip: text
},
Kirigami.Action {
id: emojiAction
onClicked: {
if (Clipboard.hasImage) {
attachDialog.open()
} else {
var fileDialog = openFileDialog.createObject(QQC2.ApplicationWindow.overlay)
fileDialog.chosen.connect((path) => {
if (!path) {
return;
}
currentRoom.chatBoxAttachmentPath = path;
})
fileDialog.open()
}
}
property bool isBusy: false
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
}
QQC2.BusyIndicator {
id: uploadingBusySpinner
anchors.fill: parent
visible: running
running: currentRoom && currentRoom.hasFileUploading
}
}
QQC2.ToolButton {
id: emojiButton
icon.name: "smiley"
text: i18n("Add an Emoji")
display: QQC2.AbstractButton.IconOnly
displayHint: Kirigami.DisplayHint.IconOnly
checkable: true
onClicked: {
onTriggered: {
if (emojiDialog.visible) {
emojiDialog.close()
} else {
@@ -211,29 +68,266 @@ QQC2.ToolBar {
}
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
}
tooltip: text
},
Kirigami.Action {
id: sendAction
property bool isBusy: false
QQC2.ToolButton {
id: sendButton
icon.name: "document-send"
text: i18n("Send message")
display: QQC2.AbstractButton.IconOnly
displayHint: Kirigami.DisplayHint.IconOnly
checkable: true
onClicked: {
chatBar.postMessage()
onTriggered: {
root.postMessage()
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
tooltip: text
}
]
leftPadding: 0
rightPadding: 0
topPadding: 0
bottomPadding: 0
contentItem: QQC2.ScrollView {
id: chatBarScrollView
property var textFieldHeight: textField.height
property var visualLeftPadding: (root.width - chatBoxMaxWidth) / 2 - (root.width > chatBoxMaxWidth ? Kirigami.Units.largeSpacing : 0)
property var visualRightPadding: (root.width - chatBoxMaxWidth) / 2 + (root.width > chatBoxMaxWidth ? Kirigami.Units.largeSpacing + Kirigami.Units.smallSpacing : 0)
leftPadding: LayoutMirroring.enabled ? visualRightPadding : visualLeftPadding
rightPadding: LayoutMirroring.enabled ? visualLeftPadding : visualRightPadding
// HACK: This is to stop the ScrollBar flickering on and off as the height is increased
QQC2.ScrollBar.vertical.policy: chatBarHeightAnimation.running && implicitHeight <= height ? QQC2.ScrollBar.AlwaysOff : QQC2.ScrollBar.AsNeeded
Behavior on implicitHeight {
NumberAnimation {
id: chatBarHeightAnimation
duration: Kirigami.Units.shortDuration
easing.type: Easing.InOutCubic
}
}
QQC2.TextArea{
id: textField
topPadding: Kirigami.Units.largeSpacing + (paneLoader.visible ? paneLoader.height : 0)
bottomPadding: Kirigami.Units.largeSpacing
leftPadding: LayoutMirroring.enabled ? actionsRow.width : (root.width > chatBoxMaxWidth ? 0 : Kirigami.Units.largeSpacing)
rightPadding: LayoutMirroring.enabled ? (root.width > chatBoxMaxWidth ? 0 : Kirigami.Units.largeSpacing) : actionsRow.width
placeholderText: readOnly ? i18n("This room is encrypted. Build libQuotient with encryption enabled to send encrypted messages.") : currentRoom.chatBoxEditId.length > 0 ? i18n("Edit Message") : currentRoom.usesEncryption ? i18n("Send an encrypted message…") : currentRoom.chatBoxAttachmentPath.length > 0 ? i18n("Set an attachment caption...") : i18n("Send a message…")
verticalAlignment: TextEdit.AlignVCenter
wrapMode: Text.Wrap
readOnly: (currentRoom.usesEncryption && !Controller.encryptionSupported)
Timer {
id: repeatTimer
interval: 5000
}
onTextChanged: {
if (!repeatTimer.running && Config.typingNotifications) {
var textExists = text.length > 0
currentRoom.sendTypingNotification(textExists)
textExists ? repeatTimer.start() : repeatTimer.stop()
}
currentRoom.chatBoxText = text
}
onCursorRectangleChanged: chatBarScrollView.ensureVisible(cursorRectangle)
Keys.onEnterPressed: {
if (completionMenu.visible) {
completionMenu.complete()
} else if (event.modifiers & Qt.ShiftModifier) {
textField.insert(cursorPosition, "\n")
} else {
chatBar.postMessage();
}
}
Keys.onReturnPressed: {
if (completionMenu.visible) {
completionMenu.complete()
} else if (event.modifiers & Qt.ShiftModifier) {
textField.insert(cursorPosition, "\n")
} else {
chatBar.postMessage();
}
}
Keys.onTabPressed: {
if (completionMenu.visible) {
completionMenu.complete()
}
}
Keys.onPressed: {
if (event.key === Qt.Key_V && event.modifiers & Qt.ControlModifier) {
chatBar.pasteImage();
} else if (event.key === Qt.Key_Up && event.modifiers & Qt.ControlModifier) {
let replyEvent = messageEventModel.getLatestMessageFromIndex(0)
if (replyEvent && replyEvent["event_id"]) {
currentRoom.chatBoxReplyId = replyEvent["event_id"]
}
} else if (event.key === Qt.Key_Up && textField.text.length === 0) {
let editEvent = messageEventModel.getLastLocalUserMessageEventId()
if (editEvent) {
currentRoom.chatBoxEditId = editEvent["event_id"]
}
} else if (event.key === Qt.Key_Up && completionMenu.visible) {
completionMenu.decrementIndex()
} else if (event.key === Qt.Key_Down && completionMenu.visible) {
completionMenu.incrementIndex()
} else if (event.key === Qt.Key_Backspace && textField.text.length <= 1) {
currentRoom.sendTypingNotification(false)
repeatTimer.stop()
}
}
Loader {
id: paneLoader
anchors.top: parent.top
anchors.left: parent.left
anchors.leftMargin: root.width > chatBoxMaxWidth ? 0 : Kirigami.Units.largeSpacing
anchors.right: parent.right
anchors.rightMargin: root.width > chatBoxMaxWidth ? 0 : (chatBarScrollView.QQC2.ScrollBar.vertical.visible ? Kirigami.Units.largeSpacing * 3.5 : Kirigami.Units.largeSpacing)
active: visible
visible: root.replyPaneVisible || root.attachmentPaneVisible
sourceComponent: root.replyPaneVisible ? replyPane : attachmentPane
}
Component {
id: replyPane
ReplyPane {
userName: root.replyUser ? root.replyUser.displayName : ""
userColor: root.replyUser ? root.replyUser.color : ""
userAvatar: root.replyUser ? "image://mxc/" + currentRoom.getUser(root.replyUser.id).avatarMediaId : ""
isReply: root.isReplying
text: isEditing ? currentRoom.chatBoxEditMessage : currentRoom.chatBoxReplyMessage
}
}
Component {
id: attachmentPane
AttachmentPane {
attachmentPath: currentRoom.chatBoxAttachmentPath
onAttachmentCancelled: {
currentRoom.chatBoxAttachmentPath = "";
root.forceActiveFocus()
}
}
}
background: MouseArea {
acceptedButtons: Qt.NoButton
cursorShape: Qt.IBeamCursor
z: 1
}
}
/**
* Because of the paneLoader we have to manage the scroll
* position manually or it doesn't keep the cursor visible properly all the time.
*/
function ensureVisible(r) {
// Find the child that is the Flickable created by ScrollView.
let flickable = undefined;
for (var index in children) {
if (children[index] instanceof Flickable) {
flickable = children[index];
}
}
if (flickable) {
if (flickable.contentX >= r.x) {
flickable.contentX = r.x;
} else if (flickable.contentX + width <= r.x + r.width) {
flickable.contentX = r.x + r.width - width;
} if (flickable.contentY >= r.y) {
flickable.contentY = r.y;
} else if (flickable.contentY + height <= r.y + r.height) {
flickable.contentY = r.y + r.height - height + textField.bottomPadding;
}
}
}
}
QQC2.ToolButton {
id: cancelButton
anchors.top: parent.top
anchors.right: parent.right
anchors.rightMargin: (root.width - chatBoxMaxWidth) / 2 + Kirigami.Units.largeSpacing + (chatBarScrollView.QQC2.ScrollBar.vertical.visible && !(root.width > chatBoxMaxWidth) ? Kirigami.Units.largeSpacing * 2.5 : 0)
visible: root.replyPaneVisible
display: QQC2.AbstractButton.IconOnly
action: Kirigami.Action {
text: root.isReplying ? i18nc("@action:button", "Cancel reply") : i18nc("@action:button", "Cancel edit")
icon.name: "dialog-close"
onTriggered: {
currentRoom.chatBoxReplyId = "";
currentRoom.chatBoxEditId = "";
currentRoom.chatBoxAttachmentPath = "";
root.forceActiveFocus()
}
shortcut: "Escape"
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
}
Row {
id: actionsRow
padding: Kirigami.Units.smallSpacing
spacing: Kirigami.Units.smallSpacing
anchors.right: parent.right
property var requiredMargin: (root.width - chatBoxMaxWidth) / 2 + Kirigami.Units.largeSpacing + (chatBarScrollView.QQC2.ScrollBar.vertical.visible && !(root.width > chatBoxMaxWidth) ? Kirigami.Units.largeSpacing * 2.5 : 0)
anchors.leftMargin: layoutDirection === Qt.RightToLeft ? requiredMargin : 0
anchors.rightMargin: layoutDirection === Qt.RightToLeft ? 0 : requiredMargin
anchors.bottom: parent.bottom
anchors.bottomMargin: Kirigami.Units.largeSpacing - 2
Repeater {
model: root.actions
Kirigami.Icon {
implicitWidth: Kirigami.Units.iconSizes.smallMedium
implicitHeight: Kirigami.Units.iconSizes.smallMedium
source: modelData.isBusy ? "" : (modelData.icon.name.length > 0 ? modelData.icon.name : modelData.icon.source)
active: actionArea.containsPress
visible: modelData.visible
enabled: modelData.enabled
MouseArea {
id: actionArea
anchors.fill: parent
onClicked: modelData.trigger()
cursorShape: Qt.PointingHandCursor
}
QQC2.ToolTip.visible: modelData.tooltip !== "" && hoverHandler.hovered
QQC2.ToolTip.text: modelData.tooltip
HoverHandler { id: hoverHandler }
QQC2.BusyIndicator {
anchors.fill: parent
leftPadding: 0
rightPadding: 0
topPadding: 0
bottomPadding: 0
visible: running
running: modelData.isBusy
}
}
}
}
EmojiDialog {
id: emojiDialog
x: parent.width - implicitWidth
y: -implicitHeight - Kirigami.Units.smallSpacing
y: -implicitHeight // - Kirigami.Units.smallSpacing
modal: false
includeCustom: true
@@ -243,6 +337,10 @@ QQC2.ToolBar {
onClosed: if (emojiButton.checked) emojiButton.checked = false
}
background: Rectangle {
color: Kirigami.Theme.backgroundColor
}
CompletionMenu {
id: completionMenu
height: implicitHeight
@@ -262,22 +360,34 @@ QQC2.ToolBar {
target: currentRoom
function onChatBoxEditIdChanged() {
if (currentRoom.chatBoxEditMessage.length > 0) {
chatBar.inputFieldText = currentRoom.chatBoxEditMessage
textField.text = currentRoom.chatBoxEditMessage
}
}
}
ChatDocumentHandler {
id: documentHandler
document: inputField.textDocument
cursorPosition: inputField.cursorPosition
selectionStart: inputField.selectionStart
selectionEnd: inputField.selectionEnd
document: textField.textDocument
cursorPosition: textField.cursorPosition
selectionStart: textField.selectionStart
selectionEnd: textField.selectionEnd
Component.onCompleted: {
RoomManager.chatDocumentHandler = documentHandler;
}
}
function forceActiveFocus() {
textField.forceActiveFocus();
// set the cursor to the end of the text
textField.cursorPosition = textField.length;
}
function insertText(text) {
let initialCursorPosition = textField.cursorPosition;
textField.text = textField.text.substr(0, initialCursorPosition) + text + textField.text.substr(initialCursorPosition)
textField.cursorPosition = initialCursorPosition + text.length
}
function pasteImage() {
let localPath = Clipboard.saveImage();
@@ -291,7 +401,7 @@ QQC2.ToolBar {
actionsHandler.handleMessage();
repeatTimer.stop()
currentRoom.markAllMessagesAsRead();
inputField.clear();
textField.clear();
currentRoom.chatBoxReplyId = "";
currentRoom.chatBoxEditId = "";
messageSent()