diff --git a/src/QmlUtils.cpp b/src/QmlUtils.cpp
index 574811e..30e0468 100644
--- a/src/QmlUtils.cpp
+++ b/src/QmlUtils.cpp
@@ -1,249 +1,254 @@
/*
* Kaidan - A user-friendly XMPP client for every device!
*
* Copyright (C) 2016-2020 Kaidan developers and contributors
* (see the LICENSE file for a full list of copyright authors)
*
* Kaidan is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* In addition, as a special exception, the author of Kaidan gives
* permission to link the code of its release with the OpenSSL
* project's "OpenSSL" library (or with modified versions of it that
* use the same license as the "OpenSSL" library), and distribute the
* linked executables. You must obey the GNU General Public License in
* all respects for all of the code used other than "OpenSSL". If you
* modify this file, you may extend this exception to your version of
* the file, but you are not obligated to do so. If you do not wish to
* do so, delete this exception statement from your version.
*
* Kaidan is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kaidan. If not, see .
*/
#include "QmlUtils.h"
// Qt
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
// QXmpp
#include "qxmpp-exts/QXmppColorGenerator.h"
static QmlUtils *s_instance;
QmlUtils *QmlUtils::instance()
{
if (!s_instance)
return new QmlUtils(QGuiApplication::instance());
return s_instance;
}
QmlUtils::QmlUtils(QObject *parent)
: QObject(parent)
{
Q_ASSERT(!s_instance);
s_instance = this;
}
QmlUtils::~QmlUtils()
{
s_instance = nullptr;
}
QString QmlUtils::presenceTypeToIcon(Enums::AvailabilityTypes type)
{
switch (type) {
case AvailabilityTypes::PresOnline:
return "im-user-online";
case AvailabilityTypes::PresChat:
return "im-user-online";
case AvailabilityTypes::PresAway:
return "im-user-away";
case AvailabilityTypes::PresDND:
return "im-kick-user";
case AvailabilityTypes::PresXA:
return "im-user-away";
case AvailabilityTypes::PresUnavailable:
return "im-user-offline";
case AvailabilityTypes::PresError:
return "im-ban-kick-user";
case AvailabilityTypes::PresInvisible:
return "im-invisible-user";
}
Q_UNREACHABLE();
return { };
}
QString QmlUtils::presenceTypeToText(AvailabilityTypes type)
{
switch (type) {
case AvailabilityTypes::PresOnline:
return tr("Available");
case AvailabilityTypes::PresChat:
return tr("Free for chat");
case AvailabilityTypes::PresAway:
return tr("Away");
case AvailabilityTypes::PresDND:
return tr("Do not disturb");
case AvailabilityTypes::PresXA:
return tr("Away for longer");
case AvailabilityTypes::PresUnavailable:
return tr("Offline");
case AvailabilityTypes::PresError:
return tr("Error");
case AvailabilityTypes::PresInvisible:
return tr("Invisible");
}
Q_UNREACHABLE();
return { };
}
QColor QmlUtils::presenceTypeToColor(AvailabilityTypes type)
{
switch (type) {
case AvailabilityTypes::PresOnline:
return {"green"};
case AvailabilityTypes::PresChat:
return {"darkgreen"};
case AvailabilityTypes::PresAway:
return {"orange"};
case AvailabilityTypes::PresDND:
return QColor::fromRgb(218, 68, 83);
case AvailabilityTypes::PresXA:
return {"orange"};
case AvailabilityTypes::PresError:
return {"red"};
case AvailabilityTypes::PresUnavailable:
return {"silver"};
case AvailabilityTypes::PresInvisible:
return {"grey"};
}
Q_UNREACHABLE();
return { };
}
QString QmlUtils::connectionErrorMessage(ClientWorker::ConnectionError error)
{
switch (error) {
case ClientWorker::AuthenticationFailed:
return tr("Invalid username or password.");
case ClientWorker::NotConnected:
return tr("Cannot connect to the server. Please check your internet connection.");
case ClientWorker::TlsNotAvailable:
return tr("The server doesn't support secure connections.");
case ClientWorker::TlsFailed:
return tr("Error while trying to connect securely.");
case ClientWorker::DnsError:
return tr("Could not resolve the server's address. Please check your server name.");
case ClientWorker::ConnectionRefused:
return tr("Could not connect to the server.");
case ClientWorker::NoSupportedAuth:
return tr("Authentification protocol not supported by the server.");
case ClientWorker::RegistrationUnsupported:
return tr("This server does not support registration.");
default:
return tr("An unknown error occured.");
}
Q_UNREACHABLE();
}
QString QmlUtils::getResourcePath(const QString &name)
{
// We generally prefer to first search for files in application resources
if (QFile::exists(":/" + name))
return QString("qrc:/" + name);
// list of file paths where to search for the resource file
QStringList pathList;
// add relative path from binary (only works if installed)
pathList << QCoreApplication::applicationDirPath() + QString("/../share/") + QString(APPLICATION_NAME);
// get the standard app data locations for current platform
pathList << QStandardPaths::standardLocations(QStandardPaths::AppDataLocation);
#ifdef UBUNTU_TOUCH
pathList << QString("./share/") + QString(APPLICATION_NAME);
#endif
#ifndef NDEBUG
#ifdef DEBUG_SOURCE_PATH
// add source directory (only for debug builds)
pathList << QString(DEBUG_SOURCE_PATH) + QString("/data");
#endif
#endif
// search for file in directories
for (int i = 0; i < pathList.size(); i++) {
// open directory
QDir directory(pathList.at(i));
// look up the file
if (directory.exists(name)) {
// found the file, return the path
return QUrl::fromLocalFile(directory.absoluteFilePath(name)).toString();
}
}
// no file found
qWarning() << "[main] Could NOT find media file:" << name;
return QString();
}
bool QmlUtils::isImageFile(const QUrl &fileUrl)
{
QMimeType type = QMimeDatabase().mimeTypeForUrl(fileUrl);
return type.inherits("image/jpeg") || type.inherits("image/png");
}
void QmlUtils::copyToClipboard(const QString &text)
{
QGuiApplication::clipboard()->setText(text);
}
QString QmlUtils::fileNameFromUrl(const QUrl &url)
{
return QUrl(url).fileName();
}
QString QmlUtils::fileSizeFromUrl(const QUrl &url)
{
return QLocale::system().formattedDataSize(
QFileInfo(QUrl(url).toLocalFile()).size());
}
QString QmlUtils::formatMessage(const QString &message)
{
// escape all special XML chars (like '<' and '>')
// and spilt into words for processing
return processMsgFormatting(message.toHtmlEscaped().split(" "));
}
QColor QmlUtils::getUserColor(const QString &nickName)
{
QXmppColorGenerator::RGBColor color = QXmppColorGenerator::generateColor(nickName);
return {color.red, color.green, color.blue};
}
QString QmlUtils::processMsgFormatting(const QStringList &list, bool isFirst)
{
if (list.isEmpty())
return QString();
// link highlighting
if (list.first().startsWith("https://") || list.first().startsWith("http://"))
return (isFirst ? QString() : " ") + QString("%1").arg(list.first())
+ processMsgFormatting(list.mid(1), false);
+ // preserve newlines
+ if (list.first().contains("\n"))
+ return (isFirst ? QString() : " ") + QString(list.first()).replace("\n", "
")
+ + processMsgFormatting(list.mid(1), false);
+
return (isFirst ? QString() : " ") + list.first() + processMsgFormatting(list.mid(1), false);
}
diff --git a/src/qml/ChatPage.qml b/src/qml/ChatPage.qml
index 41c3d44..84cebdf 100644
--- a/src/qml/ChatPage.qml
+++ b/src/qml/ChatPage.qml
@@ -1,663 +1,673 @@
/*
* Kaidan - A user-friendly XMPP client for every device!
*
* Copyright (C) 2016-2020 Kaidan developers and contributors
* (see the LICENSE file for a full list of copyright authors)
*
* Kaidan is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* In addition, as a special exception, the author of Kaidan gives
* permission to link the code of its release with the OpenSSL
* project's "OpenSSL" library (or with modified versions of it that
* use the same license as the "OpenSSL" library), and distribute the
* linked executables. You must obey the GNU General Public License in
* all respects for all of the code used other than "OpenSSL". If you
* modify this file, you may extend this exception to your version of
* the file, but you are not obligated to do so. If you do not wish to
* do so, delete this exception statement from your version.
*
* Kaidan is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kaidan. If not, see .
*/
import QtQuick 2.7
import QtQuick.Layouts 1.3
import QtQuick.Controls 2.3 as Controls
import QtGraphicalEffects 1.0
import QtMultimedia 5.8 as Multimedia
import org.kde.kirigami 2.8 as Kirigami
import im.kaidan.kaidan 1.0
import EmojiModel 0.1
import MediaUtils 0.1
import "elements"
ChatPageBase {
id: root
property string chatName
property bool isWritingSpoiler
property string messageToCorrect
readonly property bool cameraAvailable: Multimedia.QtMultimedia.availableCameras.length > 0
title: chatName
keyboardNavigationEnabled: true
contextualActions: [
// Action to toggle the message search bar
Kirigami.Action {
id: searchAction
text: qsTr("Search")
icon.name: "search"
onTriggered: {
if (searchBar.active)
searchBar.close()
else
searchBar.open()
}
},
Kirigami.Action {
visible: true
icon.name: {
kaidan.notificationsMuted(kaidan.messageModel.chatPartner)
? "player-volume"
: "audio-volume-muted-symbolic"
}
text: {
kaidan.notificationsMuted(kaidan.messageModel.chatPartner)
? qsTr("Unmute notifications")
: qsTr("Mute notifications")
}
onTriggered: {
kaidan.setNotificationsMuted(
kaidan.messageModel.chatPartner,
!kaidan.notificationsMuted(kaidan.messageModel.chatPartner)
)
}
function handleNotificationsMuted(jid) {
text = kaidan.notificationsMuted(kaidan.messageModel.chatPartner)
? qsTr("Unmute notifications")
: qsTr("Mute notifications")
icon.name = kaidan.notificationsMuted(kaidan.messageModel.chatPartner)
? "player-volume"
: "audio-volume-muted-symbolic"
}
Component.onCompleted: {
kaidan.notificationsMutedChanged.connect(handleNotificationsMuted)
}
Component.onDestruction: {
kaidan.notificationsMutedChanged.disconnect(handleNotificationsMuted)
}
},
Kirigami.Action {
visible: true
icon.name: "user-identity"
text: qsTr("View profile")
onTriggered: pageStack.push(userProfilePage, {jid: kaidan.messageModel.chatPartner, name: chatName})
},
Kirigami.Action {
text: qsTr("Multimedia settings")
icon {
name: "settings-configure"
}
onTriggered: {
pageStack.push(multimediaSettingsPage, {jid: kaidan.messageModel.chatPartner, name: chatName})
}
},
Kirigami.Action {
readonly property int type: Enums.MessageType.MessageImage
text: MediaUtilsInstance.newMediaLabel(type)
enabled: root.cameraAvailable
icon {
name: MediaUtilsInstance.newMediaIconName(type)
}
onTriggered: {
sendMediaSheet.sendNewMessageType(kaidan.messageModel.chatPartner, type)
}
},
Kirigami.Action {
readonly property int type: Enums.MessageType.MessageAudio
text: MediaUtilsInstance.newMediaLabel(type)
icon {
name: MediaUtilsInstance.newMediaIconName(type)
}
onTriggered: {
sendMediaSheet.sendNewMessageType(kaidan.messageModel.chatPartner, type)
}
},
Kirigami.Action {
readonly property int type: Enums.MessageType.MessageVideo
text: MediaUtilsInstance.newMediaLabel(type)
enabled: root.cameraAvailable
icon {
name: MediaUtilsInstance.newMediaIconName(type)
}
onTriggered: {
sendMediaSheet.sendNewMessageType(kaidan.messageModel.chatPartner, type)
}
},
Kirigami.Action {
readonly property int type: Enums.MessageType.MessageGeoLocation
text: MediaUtilsInstance.newMediaLabel(type)
icon {
name: MediaUtilsInstance.newMediaIconName(type)
}
onTriggered: {
sendMediaSheet.sendNewMessageType(kaidan.messageModel.chatPartner, type)
}
},
Kirigami.Action {
visible: !isWritingSpoiler
icon.name: "password-show-off"
text: qsTr("Send a spoiler message")
onTriggered: isWritingSpoiler = true
}
]
// Message search bar
header: Item {
id: searchBar
height: active ? searchField.height + 2 * Kirigami.Units.largeSpacing : 0
clip: true
visible: height != 0
property bool active: false
Behavior on height {
SmoothedAnimation {
velocity: 200
}
}
// Background of the message search bar
Rectangle {
anchors.fill: parent
color: Kirigami.Theme.backgroundColor
}
/**
* Searches for a message containing the entered text in the search field.
*
* If a message is found for the entered text, that message is highlighted.
* If the upwards search reaches the top of the message list view, the search is restarted at the bottom to search for messages which were not included in the search yet because they were below the message at the start index.
* That behavior is not applied to an upwards search starting from the index of the most recent message (0) to avoid searching twice.
* If the downwards search reaches the bottom of the message list view, the search is restarted at the top to search for messages which were not included in the search yet because they were above the message at the start index.
*
* @param searchUpwards true for searching upwards or false for searching downwards
* @param startIndex index index of the first message to search for the entered text
*/
function search(searchUpwards, startIndex) {
let newIndex = -1
if (searchBar.active && searchField.text.length > 0) {
if (searchUpwards) {
if (startIndex === 0) {
newIndex = kaidan.messageModel.searchForMessageFromNewToOld(searchField.text)
} else {
newIndex = kaidan.messageModel.searchForMessageFromNewToOld(searchField.text, startIndex)
if (newIndex === -1)
newIndex = kaidan.messageModel.searchForMessageFromNewToOld(searchField.text, 0)
}
} else {
newIndex = kaidan.messageModel.searchForMessageFromOldToNew(searchField.text, startIndex)
if (newIndex === -1)
newIndex = kaidan.messageModel.searchForMessageFromOldToNew(searchField.text)
}
}
messageListView.currentIndex = newIndex
}
/**
* Hides the search bar and resets the last search result.
*/
function close() {
messageListView.currentIndex = -1
searchBar.active = false
}
/**
* Shows the search bar and focuses the search field.
*/
function open() {
searchField.forceActiveFocus()
searchBar.active = true
}
/**
* Searches upwards for a message containing the entered text in the search field starting from the current index of the message list view.
*/
function searchUpwardsFromBottom() {
searchBar.search(true, 0)
}
/**
* Searches upwards for a message containing the entered text in the search field starting from the current index of the message list view.
*/
function searchUpwardsFromCurrentIndex() {
searchBar.search(true, messageListView.currentIndex + 1)
}
/**
* Searches downwards for a message containing the entered text in the search field starting from the current index of the message list view.
*/
function searchDownwardsFromCurrentIndex() {
searchBar.search(false, messageListView.currentIndex - 1)
}
// Search field and ist corresponding buttons
RowLayout {
// Anchoring like this binds it to the top of the chat page.
// It makes it look like the search bar slides down from behind of the upper element.
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: Kirigami.Units.largeSpacing
Controls.Button {
text: qsTr("Close")
icon.name: "dialog-close"
onClicked: searchBar.close()
display: Controls.Button.IconOnly
flat: true
}
Kirigami.SearchField {
id: searchField
Layout.fillWidth: true
focusSequence: ""
onVisibleChanged: text = ""
onTextChanged: searchBar.searchUpwardsFromBottom()
onAccepted: searchBar.searchUpwardsFromCurrentIndex()
Keys.onUpPressed: searchBar.searchUpwardsFromCurrentIndex()
Keys.onDownPressed: searchBar.searchDownwardsFromCurrentIndex()
Keys.onEscapePressed: searchBar.close()
}
Controls.Button {
text: qsTr("Search up")
icon.name: "go-up"
display: Controls.Button.IconOnly
flat: true
onClicked: {
searchBar.searchUpwardsFromCurrentIndex()
searchField.forceActiveFocus()
}
}
Controls.Button {
text: qsTr("Search down")
icon.name: "go-down"
display: Controls.Button.IconOnly
flat: true
onClicked: {
searchBar.searchDownwardsFromCurrentIndex()
searchField.forceActiveFocus()
}
}
}
}
SendMediaSheet {
id: sendMediaSheet
}
FileChooser {
id: fileChooser
title: qsTr("Select a file")
onAccepted: sendMediaSheet.sendFile(kaidan.messageModel.chatPartner, fileUrl)
}
function openFileDialog(filterName, filter, title) {
fileChooser.filterName = filterName
fileChooser.filter = filter
if (title !== undefined)
fileChooser.title = title
fileChooser.open()
mediaDrawer.close()
}
Kirigami.OverlayDrawer {
id: mediaDrawer
edge: Qt.BottomEdge
height: Kirigami.Units.gridUnit * 8
contentItem: ListView {
id: content
orientation: Qt.Horizontal
Layout.fillHeight: true
Layout.fillWidth: true
model: [
Enums.MessageType.MessageFile,
Enums.MessageType.MessageImage,
Enums.MessageType.MessageAudio,
Enums.MessageType.MessageVideo,
Enums.MessageType.MessageDocument
]
delegate: IconButton {
height: ListView.view.height
width: height
buttonText: MediaUtilsInstance.label(model.modelData)
iconSource: MediaUtilsInstance.iconName(model.modelData)
onClicked: {
switch (model.modelData) {
case Enums.MessageType.MessageFile:
case Enums.MessageType.MessageImage:
case Enums.MessageType.MessageAudio:
case Enums.MessageType.MessageVideo:
case Enums.MessageType.MessageDocument:
openFileDialog(MediaUtilsInstance.filterName(model.modelData),
MediaUtilsInstance.filter(model.modelData),
MediaUtilsInstance.label(model.modelData))
break
case Enums.MessageType.MessageText:
case Enums.MessageType.MessageGeoLocation:
case Enums.MessageType.MessageUnknown:
break
}
}
}
}
}
// View containing the messages
ListView {
id: messageListView
verticalLayoutDirection: ListView.BottomToTop
spacing: Kirigami.Units.smallSpacing * 1.5
// Highlighting of the message containing a searched string.
highlight: Component {
id: highlightBar
Rectangle {
height: messageListView.currentIndex === -1 ? 0 : messageListView.currentItem.height + Kirigami.Units.smallSpacing * 2
width: messageListView.currentIndex === -1 ? 0 : messageListView.currentItem.width + Kirigami.Units.smallSpacing * 2
color: Kirigami.Theme.hoverColor
// This is used to make the highlight bar a little bit bigger than the highlighted message.
// It works only together with "messageListView.highlightFollowsCurrentItem: false".
y: messageListView.currentIndex === -1 ? 0 : messageListView.currentItem.y - Kirigami.Units.smallSpacing
x: messageListView.currentIndex === -1 ? 0 : messageListView.currentItem.x
Behavior on y {
SmoothedAnimation {
velocity: 1000
duration: 500
}
}
Behavior on height {
SmoothedAnimation {
velocity: 1000
duration: 500
}
}
}
}
// This is used to make the highlight bar a little bit bigger than the highlighted message.
highlightFollowsCurrentItem: false
// Initially highlighted value
currentIndex: -1
// Connect to the database,
model: kaidan.messageModel
delegate: ChatMessage {
msgId: model.id
sender: model.sender
sentByMe: model.sentByMe
messageBody: model.body
dateTime: new Date(model.timestamp)
isDelivered: model.isDelivered
name: chatName
mediaType: model.mediaType
mediaGetUrl: model.mediaUrl
mediaLocation: model.mediaLocation
edited: model.isEdited
isSpoiler: model.isSpoiler
isShowingSpoiler: false
spoilerHint: model.spoilerHint
onMessageEditRequested: {
messageToCorrect = id
messageField.text = body
messageField.state = "edit"
}
+ onQuoteRequested: {
+ var quotedText = ""
+ var lines = body.split("\n")
+
+ for (var line in lines) {
+ quotedText += "> " + lines[line] + "\n"
+ }
+
+ messageField.insert(0, quotedText)
+ }
}
}
// area for writing and sending a message
footer: Controls.Pane {
id: sendingArea
padding: 0
wheelEnabled: true
background: Rectangle {
id: sendingAreaBackground
color: Kirigami.Theme.backgroundColor
}
layer.enabled: sendingArea.enabled
layer.effect: DropShadow {
verticalOffset: 1
color: Qt.darker(sendingAreaBackground.color, 1.2)
samples: 20
spread: 0.3
cached: true // element is static
}
RowLayout {
anchors.fill: parent
Layout.preferredHeight: Kirigami.Units.gridUnit * 3
Controls.ToolButton {
id: attachButton
visible: kaidan.uploadServiceFound
Layout.preferredWidth: Kirigami.Units.gridUnit * 3
Layout.preferredHeight: Kirigami.Units.gridUnit * 3
padding: 0
Kirigami.Icon {
source: "document-send-symbolic"
isMask: true
smooth: true
anchors.centerIn: parent
width: Kirigami.Units.gridUnit * 2
height: width
}
onClicked: {
if (Kirigami.Settings.isMobile)
mediaDrawer.open()
else
openFileDialog(qsTr("All files"), "*", MediaUtilsInstance.label(Enums.MessageType.MessageFile))
}
}
ColumnLayout {
Layout.minimumHeight: messageField.height + Kirigami.Units.smallSpacing * 2
Layout.fillWidth: true
spacing: 0
RowLayout {
visible: isWritingSpoiler
Controls.TextArea {
id: spoilerHintField
Layout.fillWidth: true
placeholderText: qsTr("Spoiler hint")
wrapMode: Controls.TextArea.Wrap
selectByMouse: true
background: Item {}
}
Controls.ToolButton {
Layout.preferredWidth: Kirigami.Units.gridUnit * 1.5
Layout.preferredHeight: Kirigami.Units.gridUnit * 1.5
padding: 0
Kirigami.Icon {
source: "tab-close"
smooth: true
anchors.centerIn: parent
width: Kirigami.Units.gridUnit * 1.5
height: width
}
onClicked: {
isWritingSpoiler = false
spoilerHintField.text = ""
}
}
}
Kirigami.Separator {
visible: isWritingSpoiler
Layout.fillWidth: true
}
Controls.TextArea {
id: messageField
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
placeholderText: qsTr("Compose message")
wrapMode: Controls.TextArea.Wrap
selectByMouse: true
background: Item {}
state: "compose"
states: [
State {
name: "compose"
},
State {
name: "edit"
}
]
Keys.onReturnPressed: {
if (event.key === Qt.Key_Return) {
if (event.modifiers & Qt.ControlModifier) {
messageField.append("")
} else {
sendButton.onClicked()
event.accepted = true
}
}
}
}
}
EmojiPicker {
x: -width + parent.width
y: -height - 16
width: Kirigami.Units.gridUnit * 20
height: Kirigami.Units.gridUnit * 15
id: emojiPicker
model: EmojiProxyModel {
group: Emoji.Group.People
sourceModel: EmojiModel {}
}
textArea: messageField
}
Controls.ToolButton {
id: emojiPickerButton
Layout.preferredWidth: Kirigami.Units.gridUnit * 3
Layout.preferredHeight: Kirigami.Units.gridUnit * 3
padding: 0
Kirigami.Icon {
source: "face-smile"
enabled: sendButton.enabled
isMask: false
smooth: true
anchors.centerIn: parent
width: Kirigami.Units.gridUnit * 2
height: width
}
onClicked: emojiPicker.visible ? emojiPicker.close() : emojiPicker.open()
}
Controls.ToolButton {
id: sendButton
Layout.preferredWidth: Kirigami.Units.gridUnit * 3
Layout.preferredHeight: Kirigami.Units.gridUnit * 3
padding: 0
Kirigami.Icon {
source: {
if (messageField.state == "compose")
return "document-send"
else if (messageField.state == "edit")
return "edit-symbolic"
}
enabled: sendButton.enabled
isMask: true
smooth: true
anchors.centerIn: parent
width: Kirigami.Units.gridUnit * 2
height: width
}
onClicked: {
// don't send empty messages
if (!messageField.text.length) {
return
}
// disable the button to prevent sending
// the same message several times
sendButton.enabled = false
// send the message
if (messageField.state == "compose") {
kaidan.sendMessage(
kaidan.messageModel.chatPartner,
messageField.text,
isWritingSpoiler,
spoilerHintField.text
)
} else if (messageField.state == "edit") {
kaidan.correctMessage(
kaidan.messageModel.chatPartner,
messageToCorrect,
messageField.text
)
}
// clean up the text fields
messageField.text = ""
messageField.state = "compose"
spoilerHintField.text = ""
isWritingSpoiler = false
messageToCorrect = ''
// reenable the button
sendButton.enabled = true
}
}
}
}
}
diff --git a/src/qml/elements/ChatMessage.qml b/src/qml/elements/ChatMessage.qml
index 737c2dc..5287a88 100644
--- a/src/qml/elements/ChatMessage.qml
+++ b/src/qml/elements/ChatMessage.qml
@@ -1,329 +1,337 @@
/*
* Kaidan - A user-friendly XMPP client for every device!
*
* Copyright (C) 2016-2020 Kaidan developers and contributors
* (see the LICENSE file for a full list of copyright authors)
*
* Kaidan is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* In addition, as a special exception, the author of Kaidan gives
* permission to link the code of its release with the OpenSSL
* project's "OpenSSL" library (or with modified versions of it that
* use the same license as the "OpenSSL" library), and distribute the
* linked executables. You must obey the GNU General Public License in
* all respects for all of the code used other than "OpenSSL". If you
* modify this file, you may extend this exception to your version of
* the file, but you are not obligated to do so. If you do not wish to
* do so, delete this exception statement from your version.
*
* Kaidan is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kaidan. If not, see .
*/
import QtQuick 2.7
import QtQuick.Layouts 1.3
import QtQuick.Controls 2.3 as Controls
import QtGraphicalEffects 1.0
import org.kde.kirigami 2.0 as Kirigami
import im.kaidan.kaidan 1.0
import MediaUtils 0.1
RowLayout {
id: root
property string msgId
property string sender
property bool sentByMe: true
property string messageBody
property date dateTime
property bool isDelivered: false
property int mediaType
property string mediaGetUrl
property string mediaLocation
property bool edited
property bool isLoading: kaidan.transferCache.hasUpload(msgId)
property string name
property TransferJob upload: {
if (mediaType !== Enums.MessageType.MessageText && isLoading) {
return kaidan.transferCache.jobByMessageId(model.id)
}
return null
}
property bool isSpoiler
property string spoilerHint
property bool isShowingSpoiler: false
property string avatarUrl: kaidan.avatarStorage.getAvatarUrl(sender)
signal messageEditRequested(string id, string body)
+ signal quoteRequested(string body)
// Own messages are on the right, others on the left side.
layoutDirection: sentByMe ? Qt.RightToLeft : Qt.LeftToRight
spacing: 8
width: ListView.view.width
// placeholder
Item {
Layout.preferredWidth: root.layoutDirection === Qt.LeftToRight ? 5 : 10
}
Avatar {
id: avatar
visible: !sentByMe
avatarUrl: root.avatarUrl
Layout.alignment: Qt.AlignHCenter | Qt.AlignTop
name: root.name
Layout.preferredHeight: Kirigami.Units.gridUnit * 2.2
Layout.preferredWidth: Kirigami.Units.gridUnit * 2.2
}
// message bubble
Item {
Layout.preferredWidth: content.width + 16
Layout.preferredHeight: content.height + 16
// glow effect around the inner area of the message bubble
RectangularGlow {
anchors.fill: messageBubble
glowRadius: 0.8
spread: 0.3
cornerRadius: messageBubble.radius + glowRadius
color: Qt.darker(messageBubble.color, 1.2)
}
// inner area of the message bubble
Rectangle {
id: messageBubble
anchors.fill: parent
radius: roundedCornersRadius
color: sentByMe ? rightMessageBubbleColor : leftMessageBubbleColor
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: {
if (mouse.button === Qt.RightButton)
contextMenu.popup()
}
onPressAndHold: {
contextMenu.popup()
}
}
Controls.Menu {
id: contextMenu
Controls.MenuItem {
text: qsTr("Copy Message")
enabled: bodyLabel.visible
onTriggered: {
if (!isSpoiler || isShowingSpoiler)
Utils.copyToClipboard(messageBody);
else
Utils.copyToClipboard(spoilerHint);
}
}
Controls.MenuItem {
text: qsTr("Edit Message")
enabled: kaidan.messageModel.canCorrectMessage(msgId)
onTriggered: root.messageEditRequested(msgId, messageBody)
}
Controls.MenuItem {
text: qsTr("Copy download URL")
enabled: mediaGetUrl
onTriggered: Utils.copyToClipboard(mediaGetUrl)
}
+
+ Controls.MenuItem {
+ text: qsTr("Quote")
+ onTriggered: {
+ root.quoteRequested(messageBody)
+ }
+ }
}
}
ColumnLayout {
id: content
spacing: 5
anchors.centerIn: parent
RowLayout {
id: spoilerHintRow
visible: isSpoiler
Controls.Label {
text: spoilerHint == "" ? qsTr("Spoiler") : spoilerHint
color: Kirigami.Theme.textColor
font.pixelSize: Kirigami.Units.gridUnit * 0.8
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: {
if (mouse.button === Qt.LeftButton) {
isShowingSpoiler = !isShowingSpoiler
}
}
}
}
Item {
Layout.fillWidth: true
height: 1
}
Kirigami.Icon {
height: 28
width: 28
source: isShowingSpoiler ? "password-show-off" : "password-show-on"
color: Kirigami.Theme.textColor
}
}
Kirigami.Separator {
visible: isSpoiler
Layout.fillWidth: true
color: {
var bgColor = Kirigami.Theme.backgroundColor
var textColor = Kirigami.Theme.textColor
return Qt.tint(textColor, Qt.rgba(bgColor.r, bgColor.g, bgColor.b, 0.7))
}
}
ColumnLayout {
visible: isSpoiler && isShowingSpoiler || !isSpoiler
Controls.ToolButton {
visible: {
switch (root.mediaType) {
case Enums.MessageType.MessageUnknown:
case Enums.MessageType.MessageText:
case Enums.MessageType.MessageGeoLocation:
break
case Enums.MessageType.MessageImage:
case Enums.MessageType.MessageAudio:
case Enums.MessageType.MessageVideo:
case Enums.MessageType.MessageFile:
case Enums.MessageType.MessageDocument:
return !root.isLoading && root.mediaGetUrl !== ""
&& (root.mediaLocation === "" || !MediaUtilsInstance.localFileAvailable(media.mediaSource))
}
return false
}
text: qsTr("Download")
onClicked: {
print("Downloading " + mediaGetUrl + "...")
kaidan.downloadMedia(msgId, mediaGetUrl)
}
}
MediaPreviewLoader {
id: media
mediaSource: {
switch (root.mediaType) {
case Enums.MessageType.MessageUnknown:
case Enums.MessageType.MessageText:
break
case Enums.MessageType.MessageGeoLocation:
return root.mediaLocation
case Enums.MessageType.MessageImage:
case Enums.MessageType.MessageAudio:
case Enums.MessageType.MessageVideo:
case Enums.MessageType.MessageFile:
case Enums.MessageType.MessageDocument:
const localFile = root.mediaLocation !== ''
? MediaUtilsInstance.fromLocalFile(root.mediaLocation)
: ''
return MediaUtilsInstance.localFileAvailable(localFile) ? localFile : root.mediaGetUrl
}
return ''
}
mediaSourceType: root.mediaType
showOpenButton: true
message: root
}
// message body
Controls.Label {
id: bodyLabel
visible: (root.mediaType === Enums.MessageType.MessageText || messageBody !== mediaGetUrl) && messageBody !== ""
text: Utils.formatMessage(messageBody)
textFormat: Text.StyledText
wrapMode: Text.Wrap
color: Kirigami.Theme.textColor
onLinkActivated: Qt.openUrlExternally(link)
Layout.maximumWidth: media.enabled
? media.width
: root.width - Kirigami.Units.gridUnit * 6
}
Kirigami.Separator {
visible: isSpoiler && isShowingSpoiler
Layout.fillWidth: true
color: {
var bgColor = Kirigami.Theme.backgroundColor
var textColor = Kirigami.Theme.textColor
return Qt.tint(textColor, Qt.rgba(bgColor.r, bgColor.g, bgColor.b, 0.7))
}
}
}
// message meta data: date, isDelivered
RowLayout {
Layout.bottomMargin: -4
Controls.Label {
id: dateLabel
text: Qt.formatDateTime(dateTime, "dd. MMM yyyy, hh:mm")
color: Kirigami.Theme.disabledTextColor
font.pixelSize: Kirigami.Units.gridUnit * 0.8
}
Image {
id: checkmark
visible: (sentByMe && isDelivered)
Layout.preferredHeight: Kirigami.Units.gridUnit * 0.65
Layout.preferredWidth: Kirigami.Units.gridUnit * 0.65
sourceSize.height: Kirigami.Units.gridUnit * 0.65
sourceSize.width: Kirigami.Units.gridUnit * 0.65
}
Kirigami.Icon {
source: "edit-symbolic"
visible: edited
Layout.preferredHeight: Kirigami.Units.gridUnit * 0.65
Layout.preferredWidth: Kirigami.Units.gridUnit * 0.65
}
}
// progress bar for upload/download status
Controls.ProgressBar {
visible: isLoading
value: upload ? upload.progress : 0
Layout.fillWidth: true
Layout.maximumWidth: Kirigami.Units.gridUnit * 14
}
}
}
// placeholder
Item {
Layout.fillWidth: true
}
function updateIsLoading() {
isLoading = kaidan.transferCache.hasUpload(msgId)
}
Component.onCompleted: {
kaidan.transferCache.jobsChanged.connect(updateIsLoading)
}
Component.onDestruction: {
kaidan.transferCache.jobsChanged.disconnect(updateIsLoading)
}
}