diff --git a/TODO.md b/TODO.md index 3a97a228..e2c6544a 100644 --- a/TODO.md +++ b/TODO.md @@ -7,12 +7,12 @@ - Use new default/reset controls system - Display name field text should be colored -- Split `HScrollableTextArea` into `HTextArea` and `HScrollView` components -- Refactor `Composer` - - Drop the `HBox` `buttonModel`/`buttonCallbacks` `HBox` approach, be more declarative +- Reorder QML object declarations, + conform to https://doc-snapshots.qt.io/qt5-dev/qml-codingconventions.html + ## Issues - SSL error on python 3.7 diff --git a/src/gui/Base/HScrollableTextArea.qml b/src/gui/Base/HScrollableTextArea.qml index 012a9720..55ed253d 100644 --- a/src/gui/Base/HScrollableTextArea.qml +++ b/src/gui/Base/HScrollableTextArea.qml @@ -1,6 +1,5 @@ // SPDX-License-Identifier: LGPL-3.0-or-later - import QtQuick 2.12 import QtQuick.Controls 2.12 @@ -25,7 +24,6 @@ ScrollView { property var saveId: "ALL" property var saveProperties: ["text"] - property alias backgroundColor: textAreaBackground.color property alias placeholderText: textArea.placeholderText property alias placeholderTextColor: textArea.placeholderTextColor property alias area: textArea diff --git a/src/gui/Base/HTextArea.qml b/src/gui/Base/HTextArea.qml new file mode 100644 index 00000000..0d5d8704 --- /dev/null +++ b/src/gui/Base/HTextArea.qml @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +import QtQuick 2.12 +import QtQuick.Controls 2.12 + +TextArea { + id: textArea + + + property string saveName: "" + property var saveId: "ALL" + property var saveProperties: ["text"] + + property var focusItemOnTab: null + property var disabledText: null + property string defaultText: "" + readonly property bool changed: text !== defaultText + + property alias backgroundColor: textAreaBackground.color + + + function reset() { clear(); text = defaultText } + function append(text) { insert(cursorPosition, text) } + + + text: defaultText + opacity: enabled ? 1 : theme.disabledElementsOpacity + selectByMouse: true + leftPadding: theme.spacing + rightPadding: leftPadding + topPadding: theme.spacing / 1.5 + bottomPadding: topPadding + readOnly: ! visible + + wrapMode: TextEdit.Wrap + font.family: theme.fontFamily.sans + font.pixelSize: theme.fontSize.normal + font.pointSize: -1 + + placeholderTextColor: theme.controls.textArea.placeholderText + color: theme.controls.textArea.text + + background: Rectangle { + id: textAreaBackground + color: theme.controls.textArea.background + radius: theme.radius + } + + // Set it only on component creation to avoid binding loops + Component.onCompleted: if (! text) { + text = window.getState(this, "text", "") + textArea.cursorPosition = text.length + } + + onTextChanged: window.saveState(this) + + Keys.onPressed: if ( + event.modifiers & Qt.AltModifier || + event.modifiers & Qt.MetaModifier + ) event.accepted = true + + KeyNavigation.priority: KeyNavigation.BeforeItem + KeyNavigation.tab: focusItemOnTab + + + Binding on color { + value: "transparent" + when: disabledText !== null && ! textArea.enabled + } + + Binding on placeholderTextColor { + value: "transparent" + when: disabledText !== null && ! textArea.enabled + } + + Binding on implicitHeight { + value: disabledTextLabel.implicitHeight + when: disabledText !== null && ! textArea.enabled + } + + Behavior on opacity { HNumberAnimation {} } + Behavior on color { HColorAnimation {} } + Behavior on placeholderTextColor { HColorAnimation {} } + + HLabel { + id: disabledTextLabel + anchors.fill: parent + visible: opacity > 0 + opacity: disabledText !== null && parent.enabled ? 0 : 1 + text: disabledText || "" + + leftPadding: parent.leftPadding + rightPadding: parent.rightPadding + topPadding: parent.topPadding + bottomPadding: parent.bottomPadding + + wrapMode: + parent.wrapMode === TextEdit.Wrap ? Text.Wrap : + parent.wrapMode === TextEdit.WordWrap ? Text.WordWrap : + parent.wrapMode === TextEdit.WrapAnywhere ? Text.WrapAnywhere : + Text.NoWrap + + font.family: parent.font.family + font.pixelSize: parent.font.pixelSize + + Behavior on opacity { HNumberAnimation {} } + } +} diff --git a/src/gui/Pages/Chat/ChatPage.qml b/src/gui/Pages/Chat/ChatPage.qml index 644a2757..5d62d61d 100644 --- a/src/gui/Pages/Chat/ChatPage.qml +++ b/src/gui/Pages/Chat/ChatPage.qml @@ -4,8 +4,9 @@ import QtQuick 2.12 import QtQuick.Layouts 1.12 import "../../Base" import "Banners" -import "Timeline" +import "Composer" import "FileTransfer" +import "Timeline" HColumnPage { id: chatPage @@ -101,6 +102,7 @@ HColumnPage { LeftBanner { id: leftBanner visible: chat.roomInfo.left + Layout.fillWidth: true } @@ -109,6 +111,8 @@ HColumnPage { eventList: loadEventList ? eventListLoader.item.eventList : null visible: ! chat.roomInfo.left && ! chat.roomInfo.inviter_id - } + Layout.fillWidth: true + Layout.maximumHeight: parent.height / 2 + } } diff --git a/src/gui/Pages/Chat/Composer.qml b/src/gui/Pages/Chat/Composer.qml deleted file mode 100644 index e2977192..00000000 --- a/src/gui/Pages/Chat/Composer.qml +++ /dev/null @@ -1,276 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later - -import QtQuick 2.12 -import QtQuick.Layouts 1.12 -import Clipboard 0.1 -import CppUtils 0.1 -import "../.." -import "../../Base" -import "../../Dialogs" - -Rectangle { - id: composer - color: theme.chat.composer.background - - Layout.fillWidth: true - Layout.minimumHeight: theme.baseElementsHeight - Layout.preferredHeight: areaScrollView.implicitHeight - Layout.maximumHeight: pageLoader.height / 2 - - - property HListView eventList - - property string indent: " " - - property var aliases: { - const obj = {} - - for (const [id, alia] of Object.entries(window.settings.writeAliases)){ - const room = ModelStore.get(id, "rooms").find(chat.roomId) - if (room && - ! room.inviter_id && ! room.left && room.can_send_messages) - obj[id] = alia - } - - return obj - } - - property string toSend: "" - - property string writingUserId: chat.userId - property QtObject writingUserInfo: - ModelStore.get("accounts").find(writingUserId) - - property bool textChangedSinceLostFocus: false - - property alias textArea: areaScrollView.area - - readonly property int cursorPosition: - textArea.cursorPosition - - readonly property int cursorY: - textArea.text.substring(0, cursorPosition).split("\n").length - 1 - - readonly property int cursorX: - cursorPosition - lines.slice(0, cursorY).join("").length - cursorY - - readonly property var lines: textArea.text.split("\n") - readonly property string lineText: lines[cursorY] || "" - - readonly property string lineTextUntilCursor: - lineText.substring(0, cursorX) - - // readonly property int deleteCharsOnBackspace: - // lineTextUntilCursor.match(/^ +$/) ? - // lineTextUntilCursor.match(/ {1,4}/g).slice(-1)[0].length : - // 1 - - - function takeFocus() { areaScrollView.forceActiveFocus() } - - - HRowLayout { - anchors.fill: parent - - HUserAvatar { - id: avatar - userId: writingUserId - displayName: writingUserInfo ? writingUserInfo.display_name : "" - mxc: writingUserInfo ? writingUserInfo.avatar_url : "" - radius: 0 - } - - HScrollableTextArea { - id: areaScrollView - saveName: "composer" - saveId: [chat.roomId, writingUserId] - - enabled: chat.roomInfo.can_send_messages - disabledText: - qsTr("You do not have permission to post in this room") - placeholderText: qsTr("Type a message...") - - backgroundColor: "transparent" - area.tabStopDistance: 4 * 4 // 4 spaces - area.focus: true - - Layout.fillHeight: true - Layout.fillWidth: true - - - function setTyping(typing) { - py.callClientCoro( - writingUserId, - "room_typing", - [chat.roomId, typing, 5000] - ) - } - - onTextChanged: { - if (utils.isEmptyObject(aliases)) { - writingUserId = Qt.binding(() => chat.userId) - toSend = text - setTyping(Boolean(text)) - textChangedSinceLostFocus = true - return - } - - let foundAlias = null - - for (const [user, writing_alias] of Object.entries(aliases)) { - if (text.startsWith(writing_alias + " ")) { - writingUserId = user - foundAlias = new RegExp("^" + writing_alias + " ") - break - } - } - - if (foundAlias) { - toSend = text.replace(foundAlias, "") - setTyping(Boolean(text)) - textChangedSinceLostFocus = true - return - } - - writingUserId = Qt.binding(() => chat.userId) - toSend = text - - const vals = Object.values(aliases) - - const longestAlias = - vals.reduce((a, b) => a.length > b.length ? a: b) - - const textNotStartsWithAnyAlias = - ! vals.some(a => a.startsWith(text)) - - const textContainsCharNotInAnyAlias = - vals.every(a => text.split("").some(c => ! a.includes(c))) - - // Only set typing when it's sure that the user will not use - // an alias and has written something - if (toSend && - (text.length > longestAlias.length || - textNotStartsWithAnyAlias || - textContainsCharNotInAnyAlias)) - { - setTyping(Boolean(text)) - textChangedSinceLostFocus = true - } - } - - area.onEditingFinished: { // when lost focus - if (text && textChangedSinceLostFocus) { - setTyping(false) - textChangedSinceLostFocus = false - } - } - - Component.onCompleted: { - area.Keys.onEscapePressed.connect(ev => { - if (chat.replyToEventId) { - chat.replyToEventId = "" - chat.replyToUserId = "" - chat.replyToDisplayName = "" - } - }) - - area.Keys.onReturnPressed.connect(ev => { - ev.accepted = true - - if (ev.modifiers & Qt.ShiftModifier || - ev.modifiers & Qt.ControlModifier || - ev.modifiers & Qt.AltModifier) - { - let indents = 0 - const parts = lineText.split(indent) - - for (const [i, part] of parts.entries()) { - if (i === parts.length - 1 || part) { break } - indents += 1 - } - - const add = indent.repeat(indents) - textArea.insert(cursorPosition, "\n" + add) - return - } - - if (textArea.text === "") { return } - - const args = [chat.roomId, toSend, chat.replyToEventId] - py.callClientCoro(writingUserId, "send_text", args) - - area.clear() - chat.replyToEventId = "" - chat.replyToUserId = "" - chat.replyToDisplayName = "" - }) - - area.Keys.onEnterPressed.connect(area.Keys.onReturnPressed) - - area.Keys.onTabPressed.connect(ev => { - ev.accepted = true - textArea.insert(cursorPosition, indent) - }) - - area.Keys.onPressed.connect(ev => { - if (ev.matches(StandardKey.Copy) && - ! area.selectedText && - eventList && - (eventList.selectedCount || - eventList.currentIndex !== -1)) { - - ev.accepted = true - eventList.copySelectedDelegates() - return - } - - // FIXME: buggy - // if (ev.modifiers === Qt.NoModifier && - // ev.key === Qt.Key_Backspace && - // ! textArea.selectedText) - // { - // ev.accepted = true - // textArea.remove( - // cursorPosition - deleteCharsOnBackspace, - // cursorPosition - // ) - // } - }) - } - } - - HButton { - enabled: chat.roomInfo.can_send_messages - icon.name: "upload-file" - backgroundColor: theme.chat.composer.uploadButton.background - toolTip.text: - chat.userInfo.max_upload_size ? - qsTr("Send files (%1 max)").arg( - CppUtils.formattedBytes(chat.userInfo.max_upload_size, 0), - ) : - qsTr("Send files") - - onClicked: sendFilePicker.dialog.open() - - Layout.fillHeight: true - - HShortcut { - sequences: window.settings.keys.sendFileFromPathInClipboard - onActivated: utils.sendFile( - chat.userId, chat.roomId, Clipboard.text.trim(), - ) - } - - SendFilePicker { - id: sendFilePicker - userId: chat.userId - roomId: chat.roomId - - HShortcut { - sequences: window.settings.keys.sendFile - onActivated: sendFilePicker.dialog.open() - } - } - } - } -} diff --git a/src/gui/Pages/Chat/Composer/Composer.qml b/src/gui/Pages/Chat/Composer/Composer.qml new file mode 100644 index 00000000..667afa63 --- /dev/null +++ b/src/gui/Pages/Chat/Composer/Composer.qml @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +import QtQuick 2.12 +import QtQuick.Controls 2.12 +import QtQuick.Layouts 1.12 +import "../../../Base" + +Rectangle { + property alias eventList: messageArea.eventList + + + function takeFocus() { messageArea.forceActiveFocus() } + + + implicitHeight: + Math.max(theme.baseElementsHeight, messageArea.implicitHeight) + + color: theme.chat.composer.background + + + HRowLayout { + anchors.fill: parent + + HUserAvatar { + id: avatar + radius: 0 + userId: messageArea.writingUserId + + mxc: + messageArea.writingUserInfo ? + messageArea.writingUserInfo.avatar_url : + "" + + displayName: + messageArea.writingUserInfo ? + messageArea.writingUserInfo.display_name : + "" + } + + ScrollView { + Layout.fillHeight: true + Layout.fillWidth: true + + MessageArea { id: messageArea} + } + + UploadButton { + Layout.fillHeight: true + } + } +} diff --git a/src/gui/Pages/Chat/Composer/MessageArea.qml b/src/gui/Pages/Chat/Composer/MessageArea.qml new file mode 100644 index 00000000..9156cdee --- /dev/null +++ b/src/gui/Pages/Chat/Composer/MessageArea.qml @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +import QtQuick 2.12 +import "../../.." +import "../../../Base" + +HTextArea { + id: textArea + + + property HListView eventList + + property string indent: " " + + property string toSend: "" + property bool textChangedSinceLostFocus: false + property string writingUserId: chat.userId + + readonly property QtObject writingUserInfo: + ModelStore.get("accounts").find(writingUserId) + + readonly property int cursorY: + textArea.text.substring(0, cursorPosition).split("\n").length - 1 + + readonly property int cursorX: + cursorPosition - lines.slice(0, cursorY).join("").length - cursorY + + readonly property var lines: textArea.text.split("\n") + readonly property string lineText: lines[cursorY] || "" + + readonly property string lineTextUntilCursor: + lineText.substring(0, cursorX) + + // readonly property int deleteCharsOnBackspace: + // lineTextUntilCursor.match(/^ +$/) ? + // lineTextUntilCursor.match(/ {1,4}/g).slice(-1)[0].length : + // 1 + + readonly property var usableAliases: { + const obj = {} + + // Get accounts that are members of this room with permission to talk + for (const [id, alia] of Object.entries(window.settings.writeAliases)){ + const room = ModelStore.get(id, "rooms").find(chat.roomId) + if (room && + ! room.inviter_id && ! room.left && room.can_send_messages) + obj[id] = alia + } + + return obj + } + + + function setTyping(typing) { + py.callClientCoro( + writingUserId, "room_typing", [chat.roomId, typing, 5000], + ) + } + + function clearReplyTo() { + if (! chat.replyToEventId) return + + chat.replyToEventId = "" + chat.replyToUserId = "" + chat.replyToDisplayName = "" + } + + function addNewLine() { + let indents = 0 + const parts = lineText.split(indent) + + for (const [i, part] of parts.entries()) { + if (i === parts.length - 1 || part) { break } + indents += 1 + } + + const add = indent.repeat(indents) + textArea.append("\n" + add) + } + + function sendText() { + if (! toSend) return + + const args = [chat.roomId, toSend, chat.replyToEventId] + py.callClientCoro(writingUserId, "send_text", args) + + textArea.clear() + clearReplyTo() + } + + + saveName: "composer" + saveId: [chat.roomId, writingUserId] + + enabled: chat.roomInfo.can_send_messages + disabledText: qsTr("You do not have permission to post in this room") + placeholderText: qsTr("Type a message...") + + backgroundColor: "transparent" + tabStopDistance: 4 * 4 // 4 spaces + focus: true + + // TODO: make this more declarative + onTextChanged: { + if (utils.isEmptyObject(usableAliases)) { + writingUserId = Qt.binding(() => chat.userId) + toSend = text + setTyping(Boolean(text)) + textChangedSinceLostFocus = true + return + } + + let foundAlias = null + + for (const [user, writing_alias] of Object.entries(usableAliases)) { + if (text.startsWith(writing_alias + " ")) { + writingUserId = user + foundAlias = new RegExp("^" + writing_alias + " ") + break + } + } + + if (foundAlias) { + toSend = text.replace(foundAlias, "") + setTyping(Boolean(text)) + textChangedSinceLostFocus = true + return + } + + writingUserId = Qt.binding(() => chat.userId) + toSend = text + + const vals = Object.values(usableAliases) + + const longestAlias = + vals.reduce((a, b) => a.length > b.length ? a: b) + + const textNotStartsWithAnyAlias = + ! vals.some(a => a.startsWith(text)) + + const textContainsCharNotInAnyAlias = + vals.every(a => text.split("").some(c => ! a.includes(c))) + + // Only set typing when it's sure that the user will not use + // an alias and has written something + if (toSend && + (text.length > longestAlias.length || + textNotStartsWithAnyAlias || + textContainsCharNotInAnyAlias)) + { + setTyping(Boolean(text)) + textChangedSinceLostFocus = true + } + } + + onEditingFinished: { // when focus is lost + if (text && textChangedSinceLostFocus) { + setTyping(false) + textChangedSinceLostFocus = false + } + } + + Keys.onEscapePressed: clearReplyTo() + + Keys.onReturnPressed: ev => { + ev.accepted = true + + ev.modifiers & Qt.ShiftModifier || + ev.modifiers & Qt.ControlModifier || + ev.modifiers & Qt.AltModifier ? + addNewLine() : + sendText() + } + + Keys.onEnterPressed: ev => Keys.returnPressed(ev) + + Keys.onTabPressed: ev => { + ev.accepted = true + textArea.append(indent) + } + + Keys.onPressed: ev => { + if (ev.matches(StandardKey.Copy) && + ! textArea.selectedText && + eventList && + (eventList.selectedCount || eventList.currentIndex !== -1)) + { + ev.accepted = true + eventList.copySelectedDelegates() + return + } + + // FIXME: buggy + // if (ev.modifiers === Qt.NoModifier && + // ev.key === Qt.Key_Backspace && + // ! textArea.selectedText) + // { + // ev.accepted = true + // textArea.remove( + // cursorPosition - deleteCharsOnBackspace, + // cursorPosition + // ) + // } + } +} diff --git a/src/gui/Pages/Chat/Composer/UploadButton.qml b/src/gui/Pages/Chat/Composer/UploadButton.qml new file mode 100644 index 00000000..263de22c --- /dev/null +++ b/src/gui/Pages/Chat/Composer/UploadButton.qml @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +import QtQuick 2.12 +import Clipboard 0.1 +import CppUtils 0.1 +import "../../../Base" +import "../../../Dialogs" + +HButton { + enabled: chat.roomInfo.can_send_messages + icon.name: "upload-file" + backgroundColor: theme.chat.composer.uploadButton.background + toolTip.text: + chat.userInfo.max_upload_size ? + qsTr("Send files (%1 max)").arg( + CppUtils.formattedBytes(chat.userInfo.max_upload_size, 0), + ) : + qsTr("Send files") + + onClicked: sendFilePicker.dialog.open() + + + HShortcut { + sequences: window.settings.keys.sendFileFromPathInClipboard + onActivated: utils.sendFile( + chat.userId, chat.roomId, Clipboard.text.trim(), + ) + } + + SendFilePicker { + id: sendFilePicker + userId: chat.userId + roomId: chat.roomId + + HShortcut { + sequences: window.settings.keys.sendFile + onActivated: sendFilePicker.dialog.open() + } + } +}