Refactor and fix ChatBox layouting

BUG: 474616
This commit is contained in:
Tobias Fella
2023-10-15 19:30:28 +02:00
parent 0beb5df08d
commit 84cad630cd
12 changed files with 544 additions and 660 deletions

View File

@@ -173,7 +173,6 @@ qt_add_qml_module(neochat URI org.kde.neochat NO_PLUGIN
qml/TypingPane.qml qml/TypingPane.qml
qml/QuickSwitcher.qml qml/QuickSwitcher.qml
qml/HoverActions.qml qml/HoverActions.qml
qml/ChatBox.qml
qml/ChatBar.qml qml/ChatBar.qml
qml/AttachmentPane.qml qml/AttachmentPane.qml
qml/ReplyPane.qml qml/ReplyPane.qml
@@ -290,6 +289,7 @@ qt_add_qml_module(neochat URI org.kde.neochat NO_PLUGIN
qml/Security.qml qml/Security.qml
qml/QrCodeMaximizeComponent.qml qml/QrCodeMaximizeComponent.qml
qml/SelectSpacesDialog.qml qml/SelectSpacesDialog.qml
qml/AttachDialog.qml
RESOURCES RESOURCES
qml/confetti.png qml/confetti.png
qml/glowdot.png qml/glowdot.png

View File

@@ -142,6 +142,7 @@ CustomEmojiModel::CustomEmojiModel(QObject *parent)
fetchEmojis(); fetchEmojis();
}); });
}); });
CustomEmojiModel::fetchEmojis();
} }
QVariant CustomEmojiModel::data(const QModelIndex &idx, int role) const QVariant CustomEmojiModel::data(const QModelIndex &idx, int role) const

66
src/qml/AttachDialog.qml Normal file
View File

@@ -0,0 +1,66 @@
// SPDX-FileCopyrightText: 2020 Carl Schwan <carl@carlschwan.de>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtCore
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.neochat
QQC2.Popup {
id: root
padding: 16
signal chosen(string path)
contentItem: RowLayout {
QQC2.ToolButton {
Layout.preferredWidth: 160
Layout.fillHeight: true
icon.name: 'mail-attachment'
text: i18n("Choose local file")
onClicked: {
root.close()
var fileDialog = openFileDialog.createObject(QQC2.ApplicationWindow.overlay)
fileDialog.chosen.connect(path => root.chosen(path))
fileDialog.open()
}
}
Kirigami.Separator {}
QQC2.ToolButton {
Layout.preferredWidth: 160
Layout.fillHeight: true
padding: 16
icon.name: 'insert-image'
text: i18n("Clipboard image")
onClicked: {
const path = StandardPaths.standardLocations(StandardPaths.CacheLocation)[0] + "/screenshots/" + (new Date()).getTime() + ".png"
if (!Clipboard.saveImage(path)) {
return;
}
root.chosen(path)
root.close();
}
}
}
Component {
id: openFileDialog
OpenFileDialog {
parentWindow: Window.window
currentFolder: StandardPaths.standardLocations(StandardPaths.HomeLocation)[0]
}
}
}

View File

@@ -2,35 +2,25 @@
// SPDX-FileCopyrightText: 2020 Noah Davis <noahadvs@gmail.com> // SPDX-FileCopyrightText: 2020 Noah Davis <noahadvs@gmail.com>
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import QtCore
import QtQuick import QtQuick
import QtQuick.Layouts
import QtQuick.Controls as QQC2 import QtQuick.Controls as QQC2
import QtQuick.Window import QtQuick.Layouts
import Qt.labs.platform as Platform
import org.kde.kirigami as Kirigami import org.kde.kirigami as Kirigami
import org.kde.neochat import org.kde.neochat
import org.kde.neochat.config import org.kde.neochat.config
/** /**
* @brief The component which handles the message sending. * @brief A component for typing and sending chat messages.
* *
* The ChatBox deals with laying out the visual elements with the ChatBar handling * This is designed to go to the bottom of the timeline and provides all the functionality
* the core functionality of displaying the current message composition before sending. * required for the user to send messages to the room.
* *
* This includes support for the following message types: * In addition when replying this component supports showing the message that is being
* - text
* - media (video, image, file)
* - emojis/stickers
* - location
*
* In addition, when replying, this component supports showing the message that is being
* replied to. * replied to.
* *
* @note There is no edit functionality here this, is handled inline by the timeline * @sa ChatBar
* text delegate.
*
* @sa ChatBox
*/ */
QQC2.Control { QQC2.Control {
id: root id: root
@@ -39,17 +29,13 @@ QQC2.Control {
* @brief The current room that user is viewing. * @brief The current room that user is viewing.
*/ */
required property NeoChatRoom currentRoom required property NeoChatRoom currentRoom
required property NeoChatConnection connection
onActiveFocusChanged: textField.forceActiveFocus()
onCurrentRoomChanged: _private.chatBarCache = currentRoom.mainCache onCurrentRoomChanged: _private.chatBarCache = currentRoom.mainCache
/**
* @brief The QQC2.TextArea object.
*
* @sa QQC2.TextArea
*/
property alias textField: textField
property NeoChatConnection connection
/** /**
* @brief The ActionsHandler object to use. * @brief The ActionsHandler object to use.
* *
@@ -64,31 +50,22 @@ QQC2.Control {
* Each of these will be visualised in the ChatBar so new actions can be added * Each of these will be visualised in the ChatBar so new actions can be added
* by appending to this list. * by appending to this list.
*/ */
property list<Kirigami.Action> actions : [ property list<Kirigami.Action> actions: [
Kirigami.Action { Kirigami.Action {
id: attachmentAction id: attachmentAction
property bool isBusy: root.currentRoom && root.currentRoom.hasFileUploading property bool isBusy: root.currentRoom && root.currentRoom.hasFileUploading
// Matrix does not allow sending attachments in replies // Matrix does not allow sending attachments in replies
visible: _private.chatBarCache.isReplying && _private.chatBarCache.attachmentPath.length === 0 visible: _private.chatBarCache.replyId.length === 0 && _private.chatBarCache.attachmentPath.length === 0
icon.name: "mail-attachment" icon.name: "mail-attachment"
text: i18n("Attach an image or file") text: i18n("Attach an image or file")
displayHint: Kirigami.DisplayHint.IconOnly displayHint: Kirigami.DisplayHint.IconOnly
onTriggered: { onTriggered: {
if (Clipboard.hasImage) { let dialog = (Clipboard.hasImage ? attachDialog : openFileDialog).createObject(applicationWindow().overlay)
attachDialog.open() dialog.chosen.connect(path => _private.chatBarCache.attachmentPath = path)
} else { dialog.open()
var fileDialog = openFileDialog.createObject(QQC2.ApplicationWindow.overlay)
fileDialog.chosen.connect((path) => {
if (!path) {
return;
}
_private.chatBarCache.attachmentPath = path;
})
fileDialog.open()
}
} }
tooltip: text tooltip: text
@@ -105,10 +82,10 @@ QQC2.Control {
checkable: true checkable: true
onTriggered: { onTriggered: {
if (emojiDialog.item.visible) { if (emojiDialog.visible) {
emojiDialog.item.close() emojiDialog.close()
} else { } else {
emojiDialog.item.open() emojiDialog.open()
} }
} }
tooltip: text tooltip: text
@@ -121,7 +98,7 @@ QQC2.Control {
displayHint: QQC2.AbstractButton.IconOnly displayHint: QQC2.AbstractButton.IconOnly
onTriggered: { onTriggered: {
locationChooserComponent.createObject(QQC2.ApplicationWindow.overlay, {room: root.currentRoom}).open() locationChooser.createObject(QQC2.ApplicationWindow.overlay, {room: root.currentRoom}).open()
} }
tooltip: text tooltip: text
}, },
@@ -136,7 +113,7 @@ QQC2.Control {
checkable: true checkable: true
onTriggered: { onTriggered: {
root.postMessage() _private.postMessage()
} }
tooltip: text tooltip: text
@@ -148,294 +125,308 @@ QQC2.Control {
*/ */
signal messageSent() signal messageSent()
leftPadding: 0 spacing: 0
rightPadding: 0
Kirigami.Theme.colorSet: Kirigami.Theme.View
Kirigami.Theme.inherit: false
background: Rectangle {
color: Kirigami.Theme.backgroundColor
Kirigami.Separator {
anchors.left: parent.left
anchors.right:parent.right
anchors.top: parent.top
}
}
leftPadding: rightPadding
rightPadding: (root.width - chatBarSizeHelper.currentWidth) / 2
topPadding: 0 topPadding: 0
bottomPadding: 0 bottomPadding: 0
contentItem: QQC2.ScrollView { contentItem: ColumnLayout {
id: chatBarScrollView spacing: 0
Item { // Required to adjust for the top separator
property var textFieldHeight: textField.height Layout.preferredHeight: 1
Layout.fillWidth: true
// 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
}
} }
Loader {
id: paneLoader
QQC2.TextArea { Layout.fillWidth: true
id: textField Layout.margins: Kirigami.Units.largeSpacing
x: Math.round((root.width - chatBarSizeHelper.currentWidth) / 2) - (root.width > chatBarSizeHelper.currentWidth + Kirigami.Units.largeSpacing * 2.5 ? Kirigami.Units.largeSpacing * 1.5 : 0) active: visible
topPadding: Kirigami.Units.largeSpacing + (paneLoader.visible ? paneLoader.height : 0) visible: root.currentRoom.mainCache.replyId.length > 0 || root.currentRoom.mainCache.attachmentPath.length > 0
bottomPadding: Kirigami.Units.largeSpacing sourceComponent: root.currentRoom.mainCache.replyId.length > 0 ? replyPane : attachmentPane
leftPadding: LayoutMirroring.enabled ? actionsRow.width : Kirigami.Units.largeSpacing
rightPadding: LayoutMirroring.enabled ? Kirigami.Units.largeSpacing : actionsRow.width + x * 2 + Kirigami.Units.largeSpacing * 2
placeholderText: root.currentRoom.usesEncryption ? i18n("Send an encrypted message…") : _private.chatBarCache.attachmentPath.length > 0 ? i18n("Set an attachment caption...") : i18n("Send a message…")
verticalAlignment: TextEdit.AlignVCenter
wrapMode: Text.Wrap
Accessible.description: placeholderText
// opt-out of whatever spell checker a styled TextArea might come with
Kirigami.SpellCheck.enabled: false
Timer {
id: repeatTimer
interval: 5000
}
onTextChanged: {
if (!repeatTimer.running && Config.typingNotifications) {
var textExists = text.length > 0
root.currentRoom.sendTypingNotification(textExists)
textExists ? repeatTimer.start() : repeatTimer.stop()
}
_private.chatBarCache.text = text
}
onCursorRectangleChanged: chatBarScrollView.ensureVisible(cursorRectangle)
onSelectedTextChanged: {
if (selectedText.length > 0) {
quickFormatBar.selectionStart = selectionStart
quickFormatBar.selectionEnd = selectionEnd
quickFormatBar.open()
}
}
QuickFormatBar {
id: quickFormatBar
x: textField.cursorRectangle.x
y: textField.cursorRectangle.y - height
onFormattingSelected: root.formatText(format, selectionStart, selectionEnd)
}
Keys.onDeletePressed: {
if (selectedText.length > 0) {
remove(selectionStart, selectionEnd)
} else {
remove(cursorPosition, cursorPosition + 1)
}
if (textField.text == selectedText || textField.text.length <= 1) {
root.currentRoom.sendTypingNotification(false)
repeatTimer.stop()
}
if (quickFormatBar.visible) {
quickFormatBar.close()
}
}
Keys.onEnterPressed: event => {
if (completionMenu.visible) {
completionMenu.complete()
} else if (event.modifiers & Qt.ShiftModifier || Kirigami.Settings.isMobile) {
textField.insert(cursorPosition, "\n")
} else {
root.postMessage();
}
}
Keys.onReturnPressed: event => {
if (completionMenu.visible) {
completionMenu.complete()
} else if (event.modifiers & Qt.ShiftModifier || Kirigami.Settings.isMobile) {
textField.insert(cursorPosition, "\n")
} else {
root.postMessage();
}
}
Keys.onTabPressed: {
if (completionMenu.visible) {
completionMenu.complete()
}
}
Keys.onPressed: (event) => {
if (event.key === Qt.Key_V && event.modifiers & Qt.ControlModifier) {
event.accepted = root.pasteImage();
} else if (event.key === Qt.Key_Up && event.modifiers & Qt.ControlModifier) {
root.currentRoom.replyLastMessage();
} else if (event.key === Qt.Key_Up && textField.text.length === 0) {
root.currentRoom.editLastMessage();
} 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) {
if (textField.text == selectedText || textField.text.length <= 1) {
root.currentRoom.sendTypingNotification(false)
repeatTimer.stop()
}
if (quickFormatBar.visible && selectedText.length > 0) {
quickFormatBar.close()
}
}
}
Keys.onShortcutOverride: event => {
// Accept the event only when there was something to cancel. Otherwise, let the event go to the RoomPage.
if (cancelButton.visible && event.key === Qt.Key_Escape) {
cancelButton.action.trigger();
event.accepted = true;
}
}
Loader {
id: paneLoader
anchors.top: parent.top
anchors.left: parent.left
anchors.leftMargin: Kirigami.Units.largeSpacing
anchors.right: parent.right
anchors.rightMargin: root.width > chatBarSizeHelper.currentWidth ? 0 : (chatBarScrollView.QQC2.ScrollBar.vertical.visible ? Kirigami.Units.largeSpacing * 3.5 : Kirigami.Units.largeSpacing)
active: visible
visible: _private.chatBarCache.isReplying || _private.chatBarCache.attachmentPath.length > 0
sourceComponent: _private.chatBarCache.isReplying ? replyPane : attachmentPane
}
Component {
id: replyPane
ReplyPane {
userName: _private.chatBarCache.relationUser.displayName
userColor: _private.chatBarCache.relationUser.color
userAvatar: _private.chatBarCache.relationUser.avatarSource
text: _private.chatBarCache.relationMessage
}
}
Component {
id: attachmentPane
AttachmentPane {
attachmentPath: _private.chatBarCache.attachmentPath
onAttachmentCancelled: {
_private.chatBarCache.attachmentPath = "";
root.forceActiveFocus()
}
}
}
background: MouseArea {
acceptedButtons: Qt.NoButton
cursorShape: Qt.IBeamCursor
z: 1
}
} }
RowLayout {
QQC2.ScrollView {
id: chatBarScrollView
/** Layout.fillWidth: true
* Because of the paneLoader we have to manage the scroll Layout.maximumHeight: Kirigami.Units.gridUnit * 8
* position manually or it doesn't keep the cursor visible properly all the time.
*/ Layout.topMargin: Kirigami.Units.smallSpacing
function ensureVisible(r) { Layout.bottomMargin: Kirigami.Units.smallSpacing
// Find the child that is the Flickable created by ScrollView. Layout.minimumHeight: Kirigami.Units.gridUnit * 2
let flickable = undefined;
for (var index in children) { // HACK: This is to stop the ScrollBar flickering on and off as the height is increased
if (children[index] instanceof Flickable) { QQC2.ScrollBar.vertical.policy: chatBarHeightAnimation.running && implicitHeight <= height ? QQC2.ScrollBar.AlwaysOff : QQC2.ScrollBar.AsNeeded
flickable = children[index];
Behavior on implicitHeight {
NumberAnimation {
id: chatBarHeightAnimation
duration: Kirigami.Units.shortDuration
easing.type: Easing.InOutCubic
}
}
QQC2.TextArea {
id: textField
placeholderText: root.currentRoom.usesEncryption ? i18n("Send an encrypted message…") : root.currentRoom.mainCache.attachmentPath.length > 0 ? i18n("Set an attachment caption…") : i18n("Send a message…")
verticalAlignment: TextEdit.AlignVCenter
wrapMode: Text.Wrap
Accessible.description: placeholderText
Kirigami.SpellCheck.enabled: false
Timer {
id: repeatTimer
interval: 5000
}
onTextChanged: {
if (!repeatTimer.running && Config.typingNotifications) {
var textExists = text.length > 0
root.currentRoom.sendTypingNotification(textExists)
textExists ? repeatTimer.start() : repeatTimer.stop()
}
_private.chatBarCache.text = text
}
onSelectedTextChanged: {
if (selectedText.length > 0) {
quickFormatBar.selectionStart = selectionStart
quickFormatBar.selectionEnd = selectionEnd
quickFormatBar.open()
}
}
QuickFormatBar {
id: quickFormatBar
x: textField.cursorRectangle.x
y: textField.cursorRectangle.y - height
onFormattingSelected: root.formatText(format, selectionStart, selectionEnd)
}
Keys.onDeletePressed: {
if (selectedText.length > 0) {
remove(selectionStart, selectionEnd)
} else {
remove(cursorPosition, cursorPosition + 1)
}
if (textField.text == selectedText || textField.text.length <= 1) {
root.currentRoom.sendTypingNotification(false)
repeatTimer.stop()
}
if (quickFormatBar.visible) {
quickFormatBar.close()
}
}
Keys.onEnterPressed: event => {
if (completionMenu.visible) {
completionMenu.complete()
} else if (event.modifiers & Qt.ShiftModifier || Kirigami.Settings.isMobile) {
textField.insert(cursorPosition, "\n")
} else {
_private.postMessage();
}
}
Keys.onReturnPressed: event => {
if (completionMenu.visible) {
completionMenu.complete()
} else if (event.modifiers & Qt.ShiftModifier || Kirigami.Settings.isMobile) {
textField.insert(cursorPosition, "\n")
} else {
_private.postMessage();
}
}
Keys.onTabPressed: {
if (completionMenu.visible) {
completionMenu.complete()
}
}
Keys.onPressed: (event) => {
if (event.key === Qt.Key_V && event.modifiers & Qt.ControlModifier) {
event.accepted = _private.pasteImage();
} else if (event.key === Qt.Key_Up && event.modifiers & Qt.ControlModifier) {
root.currentRoom.replyLastMessage();
} else if (event.key === Qt.Key_Up && textField.text.length === 0) {
root.currentRoom.editLastMessage();
} 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) {
if (textField.text == selectedText || textField.text.length <= 1) {
root.currentRoom.sendTypingNotification(false)
repeatTimer.stop()
}
if (quickFormatBar.visible && selectedText.length > 0) {
quickFormatBar.close()
}
}
}
Keys.onShortcutOverride: event => {
if (completionMenu.visible) {
completionMenu.close()
} else if ((_private.chatBarCache.isReplying || _private.chatBarCache.attachmentPath.length > 0) && event.key === Qt.Key_Escape) {
_private.chatBarCache.attachmentPath = ""
_private.chatBarCache.replyId = ""
_private.chatBarCache.threadId = ""
event.accepted = true;
}
}
background: MouseArea {
acceptedButtons: Qt.NoButton
cursorShape: Qt.IBeamCursor
z: 1
}
} }
} }
RowLayout {
id: actionsRow
spacing: 0
Layout.alignment: Qt.AlignBottom
Layout.bottomMargin: Kirigami.Units.smallSpacing * 1.5
if (flickable) { Repeater {
if (flickable.contentX >= r.x) { model: root.actions
flickable.contentX = r.x; delegate: QQC2.ToolButton {
} else if (flickable.contentX + width <= r.x + r.width) { Layout.alignment: Qt.AlignVCenter
flickable.contentX = r.x + r.width - width; icon.name: modelData.isBusy ? "" : (modelData.icon.name.length > 0 ? modelData.icon.name : modelData.icon.source)
} if (flickable.contentY >= r.y) { onClicked: modelData.trigger()
flickable.contentY = r.y;
} else if (flickable.contentY + height <= r.y + r.height) { QQC2.ToolTip.visible: hovered
flickable.contentY = r.y + r.height - height + textField.bottomPadding; QQC2.ToolTip.text: modelData.tooltip
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
PieProgressBar {
visible: modelData.isBusy
progress: root.currentRoom.fileUploadingProgress
}
}
} }
} }
} }
} }
QQC2.ToolButton { DelegateSizeHelper {
id: cancelButton id: chatBarSizeHelper
anchors.top: parent.top startBreakpoint: Kirigami.Units.gridUnit * 46
anchors.right: parent.right endBreakpoint: Kirigami.Units.gridUnit * 66
anchors.rightMargin: (root.width - chatBarSizeHelper.currentWidth) / 2 + Kirigami.Units.largeSpacing + (chatBarScrollView.QQC2.ScrollBar.vertical.visible && !(root.width > chatBarSizeHelper.currentWidth) ? Kirigami.Units.largeSpacing * 2.5 : 0) startPercentWidth: 100
endPercentWidth: Config.compactLayout ? 100 : 85
maxWidth: Config.compactLayout ? -1 : Kirigami.Units.gridUnit * 60
visible: _private.chatBarCache.isReplying parentWidth: root.width
display: QQC2.AbstractButton.IconOnly }
action: Kirigami.Action {
text: i18nc("@action:button", "Cancel reply") Component {
icon.name: "dialog-close" id: replyPane
onTriggered: { ReplyPane {
userName: _private.chatBarCache.relationUser.displayName
userColor: _private.chatBarCache.relationUser.color
userAvatar: _private.chatBarCache.relationUser.avatarSource
text: _private.chatBarCache.relationMessage
onCancel: {
_private.chatBarCache.replyId = ""; _private.chatBarCache.replyId = "";
_private.chatBarCache.attachmentPath = ""; _private.chatBarCache.attachmentPath = "";
}
}
}
Component {
id: attachmentPane
AttachmentPane {
attachmentPath: _private.chatBarCache.attachmentPath
onAttachmentCancelled: {
_private.chatBarCache.attachmentPath = "";
root.forceActiveFocus() root.forceActiveFocus()
} }
} }
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
} }
RowLayout {
id: actionsRow
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.leftMargin: layoutDirection === Qt.RightToLeft ? requiredMargin : 0
anchors.rightMargin: layoutDirection === Qt.RightToLeft ? 0 : requiredMargin
anchors.bottomMargin: Kirigami.Units.smallSpacing
spacing: 0
property var requiredMargin: (root.width - chatBarSizeHelper.currentWidth) / 2 + Kirigami.Units.largeSpacing + (chatBarScrollView.QQC2.ScrollBar.vertical.visible && !(root.width > chatBarSizeHelper.currentWidth) ? Kirigami.Units.largeSpacing * 2.5 : 0)
Repeater { QtObject {
model: root.actions id: _private
delegate: QQC2.ToolButton { property ChatBarCache chatBarCache
Layout.alignment: Qt.AlignVCenter onChatBarCacheChanged: documentHandler.chatBarCache = chatBarCache
icon.name: modelData.isBusy ? "" : (modelData.icon.name.length > 0 ? modelData.icon.name : modelData.icon.source)
onClicked: modelData.trigger()
QQC2.ToolTip.visible: hovered function postMessage() {
QQC2.ToolTip.text: modelData.tooltip root.actionsHandler.handleMessageEvent(_private.chatBarCache);
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay repeatTimer.stop()
root.currentRoom.markAllMessagesAsRead();
textField.clear();
_private.chatBarCache.replyId = "";
messageSent()
}
PieProgressBar { function formatText(format, selectionStart, selectionEnd) {
visible: modelData.isBusy let index = textField.cursorPosition;
progress: root.currentRoom.fileUploadingProgress
} /*
* There cannot be white space at the beginning or end of the string for the
* formatting to work so move the sectionStart and sectionEnd markers past any whitespace.
*/
let innerText = textField.text.substr(selectionStart, selectionEnd - selectionStart);
if (innerText.charAt(innerText.length - 1) === " ") {
let trimmedRightString = innerText.replace(/\s*$/,"");
let trimDifference = innerText.length - trimmedRightString.length;
selectionEnd -= trimDifference;
}
if (innerText.charAt(0) === " ") {
let trimmedLeftString = innerText.replace(/^\s*/,"");
let trimDifference = innerText.length - trimmedLeftString.length;
selectionStart = selectionStart + trimDifference;
}
let startText = textField.text.substr(0, selectionStart);
// Needs updating with the new selectionStart and selectionEnd with white space trimmed.
innerText = textField.text.substr(selectionStart, selectionEnd - selectionStart);
let endText = textField.text.substr(selectionEnd);
textField.text = "";
textField.text = startText + format.start + innerText + format.end + format.extra + endText;
/*
* Put the cursor where it was when the popup was opened accounting for the
* new markup.
*
* The exception is for a hyperlink where it is placed ready to start typing
* the url.
*/
if (format.extra !== "") {
textField.cursorPosition = selectionEnd + format.start.length + format.end.length;
} else if (index == selectionStart) {
textField.cursorPosition = index;
} else {
textField.cursorPosition = index + format.start.length + format.end.length;
} }
} }
}
Loader { function pasteImage() {
id: emojiDialog let localPath = Clipboard.saveImage();
active: !Kirigami.Settings.isMobile if (localPath.length === 0) {
sourceComponent: EmojiDialog { return false;
x: root.width - width
y: -implicitHeight // - Kirigami.Units.smallSpacing
modal: false
includeCustom: true
closeOnChosen: false
currentRoom: root.currentRoom
onChosen: emoji => insertText(emoji)
onClosed: if (emojiAction.checked) emojiAction.checked = false
}
}
background: Rectangle {
color: Kirigami.Theme.backgroundColor
}
CompletionMenu {
id: completionMenu
height: implicitHeight
y: -height - 5
z: 1
chatDocumentHandler: documentHandler
connection: root.connection
Behavior on height {
NumberAnimation {
property: "height"
duration: Kirigami.Units.shortDuration
easing.type: Easing.OutCubic
} }
_private.chatBarCache.attachmentPath = localPath;
return true;
} }
} }
@@ -452,21 +443,59 @@ QQC2.Control {
} }
} }
DelegateSizeHelper { Component {
id: chatBarSizeHelper id: openFileDialog
startBreakpoint: Kirigami.Units.gridUnit * 46
endBreakpoint: Kirigami.Units.gridUnit * 66
startPercentWidth: 100
endPercentWidth: Config.compactLayout ? 100 : 85
maxWidth: Config.compactLayout ? -1 : Kirigami.Units.gridUnit * 60
parentWidth: root.width OpenFileDialog {
parentWindow: Window.window
currentFolder: StandardPaths.standardLocations(StandardPaths.HomeLocation)[0]
}
} }
function forceActiveFocus() { Component {
textField.forceActiveFocus(); id: attachDialog
// set the cursor to the end of the text
textField.cursorPosition = textField.length; AttachDialog {
anchors.centerIn: parent
}
}
Component {
id: locationChooser
LocationChooser {}
}
CompletionMenu {
id: completionMenu
chatDocumentHandler: documentHandler
connection: root.connection
x: 1
y: -height
width: parent.width - 1
Behavior on height {
NumberAnimation {
property: "height"
duration: Kirigami.Units.shortDuration
easing.type: Easing.OutCubic
}
}
}
EmojiDialog {
id: emojiDialog
x: root.width - width
y: -implicitHeight
modal: false
includeCustom: true
closeOnChosen: false
currentRoom: root.currentRoom
onChosen: emoji => insertText(emoji)
onClosed: if (emojiAction.checked) emojiAction.checked = false
} }
function insertText(text) { function insertText(text) {
@@ -475,139 +504,4 @@ QQC2.Control {
textField.text = textField.text.substr(0, initialCursorPosition) + text + textField.text.substr(initialCursorPosition) textField.text = textField.text.substr(0, initialCursorPosition) + text + textField.text.substr(initialCursorPosition)
textField.cursorPosition = initialCursorPosition + text.length textField.cursorPosition = initialCursorPosition + text.length
} }
function pasteImage() {
let localPath = Clipboard.saveImage();
if (localPath.length === 0) {
return false;
}
_private.chatBarCache.attachmentPath = localPath;
return true;
}
function postMessage() {
root.actionsHandler.handleMessageEvent(_private.chatBarCache);
repeatTimer.stop()
root.currentRoom.markAllMessagesAsRead();
textField.clear();
_private.chatBarCache.replyId = "";
messageSent()
}
function formatText(format, selectionStart, selectionEnd) {
let index = textField.cursorPosition;
/*
* There cannot be white space at the beginning or end of the string for the
* formatting to work so move the sectionStart and sectionEnd markers past any whitespace.
*/
let innerText = textField.text.substr(selectionStart, selectionEnd - selectionStart);
if (innerText.charAt(innerText.length - 1) === " ") {
let trimmedRightString = innerText.replace(/\s*$/,"");
let trimDifference = innerText.length - trimmedRightString.length;
selectionEnd -= trimDifference;
}
if (innerText.charAt(0) === " ") {
let trimmedLeftString = innerText.replace(/^\s*/,"");
let trimDifference = innerText.length - trimmedLeftString.length;
selectionStart = selectionStart + trimDifference;
}
let startText = textField.text.substr(0, selectionStart);
// Needs updating with the new selectionStart and selectionEnd with white space trimmed.
innerText = textField.text.substr(selectionStart, selectionEnd - selectionStart);
let endText = textField.text.substr(selectionEnd);
textField.text = "";
textField.text = startText + format.start + innerText + format.end + format.extra + endText;
/*
* Put the cursor where it was when the popup was opened accounting for the
* new markup.
*
* The exception is for a hyperlink where it is placed ready to start typing
* the url.
*/
if (format.extra !== "") {
textField.cursorPosition = selectionEnd + format.start.length + format.end.length;
} else if (index == selectionStart) {
textField.cursorPosition = index;
} else {
textField.cursorPosition = index + format.start.length + format.end.length;
}
}
Component {
id: locationChooserComponent
LocationChooser {}
}
QQC2.Popup {
anchors.centerIn: parent
id: attachDialog
padding: 16
contentItem: RowLayout {
QQC2.ToolButton {
Layout.preferredWidth: 160
Layout.fillHeight: true
icon.name: 'mail-attachment'
text: i18n("Choose local file")
onClicked: {
attachDialog.close()
var fileDialog = openFileDialog.createObject(QQC2.ApplicationWindow.overlay)
fileDialog.chosen.connect(function (path) {
if (!path) {
return;
}
_private.chatBarCache.attachmentPath = path;
})
fileDialog.open()
}
}
Kirigami.Separator {
}
QQC2.ToolButton {
Layout.preferredWidth: 160
Layout.fillHeight: true
padding: 16
icon.name: 'insert-image'
text: i18n("Clipboard image")
onClicked: {
const localPath = Platform.StandardPaths.writableLocation(Platform.StandardPaths.CacheLocation) + "/screenshots/" + (new Date()).getTime() + ".png"
if (!Clipboard.saveImage(localPath)) {
return;
}
_private.chatBarCache.attachmentPath = localPath;
attachDialog.close();
}
}
}
}
Component {
id: openFileDialog
OpenFileDialog {
parentWindow: Window.window
}
}
QtObject {
id: _private
property ChatBarCache chatBarCache
onChatBarCacheChanged: documentHandler.chatBarCache = chatBarCache
}
} }

View File

@@ -1,99 +0,0 @@
// SPDX-FileCopyrightText: 2020 Carl Schwan <carl@carlschwan.de>
// SPDX-FileCopyrightText: 2020 Noah Davis <noahadvs@gmail.com>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.neochat
/**
* @brief A component for typing and sending chat messages.
*
* This is designed to go to the bottom of the timeline and provides all the functionality
* required for the user to send messages to the room.
*
* This includes support for the following message types:
* - text
* - media (video, image, file)
* - emojis/stickers
* - location
*
* In addition when replying this component supports showing the message that is being
* replied to.
*
* @note The main role of this component is to layout the elements. The main functionality
* is handled by ChatBar
*
* @sa ChatBar
*/
ColumnLayout {
id: root
/**
* @brief The current room that user is viewing.
*/
required property NeoChatRoom currentRoom
required property NeoChatConnection connection
/**
* @brief The ActionsHandler object to use.
*
* This is expected to have the correct room set otherwise messages will be sent
* to the wrong room.
*/
required property ActionsHandler actionsHandler
/**
* @brief A message has been sent from the chat bar.
*/
signal messageSent()
/**
* @brief Insert the given text into the ChatBar.
*
* The text is inserted at the current cursor location.
*/
function insertText(text) {
chatBar.insertText(text)
}
spacing: 0
Kirigami.Theme.colorSet: Kirigami.Theme.View
Kirigami.Theme.inherit: false
Kirigami.Separator {
Layout.fillWidth: true
}
ChatBar {
id: chatBar
connection: root.connection
visible: root.currentRoom.canSendEvent("m.room.message")
Layout.fillWidth: true
Layout.minimumHeight: Math.max(Kirigami.Units.gridUnit * 2, Math.round(implicitHeight) + Kirigami.Units.largeSpacing)
// lineSpacing is height+leading, so subtract leading once since leading only exists between lines.
Layout.maximumHeight: chatBarFontMetrics.lineSpacing * 8 - chatBarFontMetrics.leading + textField.topPadding + textField.bottomPadding
Layout.preferredHeight: Math.round(implicitHeight)
currentRoom: root.currentRoom
actionsHandler: root.actionsHandler
FontMetrics {
id: chatBarFontMetrics
font: chatBar.textField.font
}
onMessageSent: {
root.messageSent();
}
}
onActiveFocusChanged: chatBar.forceActiveFocus()
}

View File

@@ -15,18 +15,19 @@ import org.kde.neochat
QQC2.Popup { QQC2.Popup {
id: root id: root
width: parent.width
required property NeoChatConnection connection required property NeoChatConnection connection
required property var chatDocumentHandler
visible: completions.count > 0 visible: completions.count > 0
onVisibleChanged: if (visible) root.open()
RoomListModel { RoomListModel {
id: roomListModel id: roomListModel
connection: root.connection connection: root.connection
} }
property var chatDocumentHandler
Component.onCompleted: { Component.onCompleted: {
chatDocumentHandler.completionModel.roomListModel = roomListModel; chatDocumentHandler.completionModel.roomListModel = roomListModel;
} }
@@ -50,40 +51,56 @@ QQC2.Popup {
implicitHeight: Math.min(completions.contentHeight, Kirigami.Units.gridUnit * 10) implicitHeight: Math.min(completions.contentHeight, Kirigami.Units.gridUnit * 10)
contentItem: ListView { contentItem: ColumnLayout {
id: completions spacing: 0
Kirigami.Separator {
Layout.fillWidth: true
}
QQC2.ScrollView {
Layout.fillWidth: true
Layout.preferredHeight: contentHeight
Layout.maximumHeight: Kirigami.Units.gridUnit * 10
anchors.fill: parent background: Rectangle {
model: root.chatDocumentHandler.completionModel color: Kirigami.Theme.backgroundColor
currentIndex: 0 }
keyNavigationWraps: true
highlightMoveDuration: 100
delegate: Delegates.RoundedItemDelegate {
id: completionDelegate
required property int index ListView {
required property string displayName id: completions
required property string subtitle
required property string iconName
text: displayName model: root.chatDocumentHandler.completionModel
currentIndex: 0
keyNavigationWraps: true
highlightMoveDuration: 100
onCountChanged: currentIndex = 0
delegate: Delegates.RoundedItemDelegate {
id: completionDelegate
contentItem: RowLayout { required property int index
KirigamiComponents.Avatar { required property string displayName
visible: completionDelegate.iconName !== "invalid" required property string subtitle
Layout.preferredWidth: Kirigami.Units.iconSizes.medium required property string iconName
Layout.preferredHeight: Kirigami.Units.iconSizes.medium
source: completionDelegate.iconName === "invalid" ? "" : completionDelegate.iconName text: displayName
name: completionDelegate.text
} contentItem: RowLayout {
Delegates.SubtitleContentItem { KirigamiComponents.Avatar {
itemDelegate: completionDelegate visible: completionDelegate.iconName !== "invalid"
labelItem.textFormat: Text.PlainText Layout.preferredWidth: Kirigami.Units.iconSizes.medium
subtitle: completionDelegate.subtitle ?? "" Layout.preferredHeight: Kirigami.Units.iconSizes.medium
subtitleItem.textFormat: Text.PlainText source: completionDelegate.iconName === "invalid" ? "" : completionDelegate.iconName
name: completionDelegate.text
}
Delegates.SubtitleContentItem {
itemDelegate: completionDelegate
labelItem.textFormat: Text.PlainText
subtitle: completionDelegate.subtitle ?? ""
subtitleItem.textFormat: Text.PlainText
}
}
onClicked: root.chatDocumentHandler.complete(completionDelegate.index)
} }
} }
onClicked: root.chatDocumentHandler.complete(completionDelegate.index)
} }
} }

View File

@@ -132,7 +132,7 @@ FormCard.FormCardPage {
id: openFileDialog id: openFileDialog
OpenFileDialog { OpenFileDialog {
folder: StandardPaths.writableLocation(StandardPaths.PicturesLocation) currentFolder: StandardPaths.standardLocations(StandardPaths.PicturesLocation)[0]
parentWindow: root.Window.window parentWindow: root.Window.window
} }
} }

View File

@@ -1,15 +1,14 @@
// SPDX-FileCopyrightText: 2019 Black Hat <bhat@encom.eu.org> // SPDX-FileCopyrightText: 2023 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: GPL-3.0-only // SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick import QtQuick
import Qt.labs.platform import QtQuick.Dialogs
FileDialog { FileDialog {
signal chosen(string path)
id: root id: root
title: i18n("Please choose a file") signal chosen(string path)
onAccepted: chosen(file) title: i18n("Select a File")
onAccepted: root.chosen(selectedFile)
} }

View File

@@ -11,82 +11,88 @@ import org.kde.kirigamiaddons.labs.components as KirigamiComponents
import org.kde.neochat import org.kde.neochat
GridLayout { RowLayout {
id: root id: root
property string userName property string userName
property color userColor: Kirigami.Theme.highlightColor property color userColor
property url userAvatar: "" property url userAvatar: ""
property var text property var text
rows: 3 signal cancel
columns: 3
rowSpacing: Kirigami.Units.smallSpacing
columnSpacing: Kirigami.Units.largeSpacing
QQC2.Label {
id: replyLabel
Layout.fillWidth: true
Layout.alignment: Qt.AlignLeft
Layout.columnSpan: 3
topPadding: Kirigami.Units.smallSpacing
text: i18n("Replying to:")
}
Rectangle { Rectangle {
id: verticalBorder id: verticalBorder
Layout.fillHeight: true Layout.fillHeight: true
Layout.rowSpan: 2
implicitWidth: Kirigami.Units.smallSpacing implicitWidth: Kirigami.Units.smallSpacing
color: userColor color: userColor
} }
KirigamiComponents.Avatar { ColumnLayout {
id: replyAvatar RowLayout {
KirigamiComponents.Avatar {
id: replyAvatar
implicitWidth: Kirigami.Units.iconSizes.small implicitWidth: Kirigami.Units.iconSizes.small
implicitHeight: Kirigami.Units.iconSizes.small implicitHeight: Kirigami.Units.iconSizes.small
source: userAvatar source: userAvatar
name: userName name: userName
color: userColor color: userColor
} }
QQC2.Label { QQC2.Label {
Layout.fillWidth: true Layout.fillWidth: true
Layout.alignment: Qt.AlignLeft Layout.alignment: Qt.AlignLeft
color: userColor color: userColor
text: userName text: userName
elide: Text.ElideRight elide: Text.ElideRight
} }
QQC2.TextArea {
id: textArea
Layout.fillWidth: true
Layout.columnSpan: 2
leftPadding: 0
rightPadding: 0
topPadding: 0
bottomPadding: 0
text: "<style> a{color:" + Kirigami.Theme.linkColor + ";}.user-pill{}</style>" + replyTextMetrics.elidedText
selectByMouse: true
selectByKeyboard: true
readOnly: true
wrapMode: QQC2.Label.Wrap
textFormat: TextEdit.RichText
background: Item {}
HoverHandler {
cursorShape: textArea.hoveredLink ? Qt.PointingHandCursor : Qt.IBeamCursor
} }
QQC2.TextArea {
id: textArea
TextMetrics { Layout.fillWidth: true
id: replyTextMetrics
text: root.text leftPadding: 0
font: textArea.font rightPadding: 0
elide: Qt.ElideRight topPadding: 0
elideWidth: textArea.width * 2 - Kirigami.Units.smallSpacing * 2 bottomPadding: 0
text: "<style> a{color:" + Kirigami.Theme.linkColor + ";}.user-pill{}</style>" + replyTextMetrics.elidedText
selectByMouse: true
selectByKeyboard: true
readOnly: true
wrapMode: QQC2.Label.Wrap
textFormat: TextEdit.RichText
background: Item {}
HoverHandler {
cursorShape: textArea.hoveredLink ? Qt.PointingHandCursor : Qt.IBeamCursor
}
TextMetrics {
id: replyTextMetrics
text: root.text
font: textArea.font
elide: Qt.ElideRight
elideWidth: textArea.width * 2 - Kirigami.Units.smallSpacing * 2
}
} }
} }
QQC2.ToolButton {
id: cancelButton
Layout.alignment: Qt.AlignVCenter
display: QQC2.AbstractButton.IconOnly
text: i18nc("@action:button", "Cancel reply")
icon.name: "dialog-close"
onClicked: {
root.cancel()
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
} }

View File

@@ -88,8 +88,8 @@ Kirigami.Page {
onCurrentRoomChanged: { onCurrentRoomChanged: {
banner.visible = false; banner.visible = false;
if (!Kirigami.Settings.isMobile && chatBoxLoader.item) { if (!Kirigami.Settings.isMobile && chatBarLoader.item) {
chatBoxLoader.item.forceActiveFocus(); chatBarLoader.item.forceActiveFocus();
} }
} }
@@ -123,9 +123,9 @@ Kirigami.Page {
messageEventModel: root.messageEventModel messageEventModel: root.messageEventModel
messageFilterModel: root.messageFilterModel messageFilterModel: root.messageFilterModel
actionsHandler: root.actionsHandler actionsHandler: root.actionsHandler
onFocusChatBox: { onFocusChatBar: {
if (chatBoxLoader.item) { if (chatBarLoader.item) {
chatBoxLoader.item.forceActiveFocus() chatBarLoader.item.forceActiveFocus()
} }
} }
connection: root.connection connection: root.connection
@@ -157,10 +157,10 @@ Kirigami.Page {
} }
footer: Loader { footer: Loader {
id: chatBoxLoader id: chatBarLoader
active: timelineViewLoader.active active: timelineViewLoader.active && root.currentRoom.canSendEvent("m.room.message") // TODO make this update in real time
sourceComponent: ChatBox { sourceComponent: ChatBar {
id: chatBox id: chatBar
width: parent.width width: parent.width
currentRoom: root.currentRoom currentRoom: root.currentRoom
connection: root.connection connection: root.connection
@@ -215,8 +215,8 @@ Kirigami.Page {
Keys.onPressed: event => { Keys.onPressed: event => {
if (!(event.modifiers & Qt.ControlModifier) && event.key < Qt.Key_Escape) { if (!(event.modifiers & Qt.ControlModifier) && event.key < Qt.Key_Escape) {
event.accepted = true; event.accepted = true;
chatBoxLoader.item.insertText(event.text); chatBarLoader.item.insertText(event.text);
chatBoxLoader.item.forceActiveFocus(); chatBarLoader.item.forceActiveFocus();
return; return;
} else if (event.key === Qt.Key_PageUp) { } else if (event.key === Qt.Key_PageUp) {
event.accepted = true; event.accepted = true;
@@ -228,7 +228,7 @@ Kirigami.Page {
} }
Connections { Connections {
target: currentRoom target: root.currentRoom
function onShowMessage(messageType, message) { function onShowMessage(messageType, message) {
banner.text = message; banner.text = message;
banner.type = messageType === ActionsHandler.Error ? Kirigami.MessageType.Error : messageType === ActionsHandler.Positive ? Kirigami.MessageType.Positive : Kirigami.MessageType.Information; banner.type = messageType === ActionsHandler.Error ? Kirigami.MessageType.Error : messageType === ActionsHandler.Positive ? Kirigami.MessageType.Positive : Kirigami.MessageType.Information;

View File

@@ -55,7 +55,7 @@ QQC2.ScrollView {
/// Used to determine if scrolling to the bottom should mark the message as unread /// Used to determine if scrolling to the bottom should mark the message as unread
property bool hasScrolledUpBefore: false; property bool hasScrolledUpBefore: false;
signal focusChatBox() signal focusChatBar()
ListView { ListView {
id: messageListView id: messageListView
@@ -166,7 +166,7 @@ QQC2.ScrollView {
action: Kirigami.Action { action: Kirigami.Action {
onTriggered: { onTriggered: {
if (!Kirigami.Settings.isMobile) { if (!Kirigami.Settings.isMobile) {
root.focusChatBox(); root.focusChatBar();
} }
messageListView.goToEvent(root.currentRoom.readMarkerEventId) messageListView.goToEvent(root.currentRoom.readMarkerEventId)
} }
@@ -258,7 +258,7 @@ QQC2.ScrollView {
HoverActions { HoverActions {
id: hoverActions id: hoverActions
currentRoom: root.currentRoom currentRoom: root.currentRoom
onFocusChatBar: root.focusChatBox() onFocusChatBar: root.focusChatBar()
} }
onContentYChanged: { onContentYChanged: {

View File

@@ -25,7 +25,7 @@ RowLayout {
Layout.topMargin: Kirigami.Units.smallSpacing Layout.topMargin: Kirigami.Units.smallSpacing
Layout.bottomMargin: Kirigami.Units.smallSpacing Layout.bottomMargin: Kirigami.Units.smallSpacing
Layout.minimumHeight: bottomEdge ? Kirigami.Units.gridUnit * 2 - 2 : -1 // HACK: -2 here is to ensure the ChatBox and the UserInfo have the same height Layout.minimumHeight: bottomEdge ? Kirigami.Units.gridUnit * 2 : -1
onVisibleChanged: { onVisibleChanged: {
if (!visible) { if (!visible) {