diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index cd238f5b7..9b31b2544 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -126,6 +126,7 @@ add_library(neochat STATIC events/pollevent.cpp pollhandler.cpp utils.h + utils.cpp registration.cpp neochatconnection.cpp neochatconnection.h @@ -284,6 +285,9 @@ qt_add_qml_module(neochat URI org.kde.neochat NO_PLUGIN qml/AccountData.qml qml/StateKeys.qml qml/UnlockSSSSDialog.qml + qml/QrScannerPage.qml + qml/JoinRoomDialog.qml + qml/ConfirmUrlDialog.qml RESOURCES qml/confetti.png qml/glowdot.png diff --git a/src/qml/ConfirmUrlDialog.qml b/src/qml/ConfirmUrlDialog.qml new file mode 100644 index 000000000..f874b053b --- /dev/null +++ b/src/qml/ConfirmUrlDialog.qml @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2024 Tobias Fella +// SPDX-License-Identifier: GPL-2.0-or-later + +import QtQuick +import QtQuick.Controls as QQC2 + +import org.kde.kirigami as Kirigami + +Kirigami.Dialog { + id: root + + property url link + + width: Kirigami.Units.gridUnit * 24 + height: Kirigami.Units.gridUnit * 8 + + title: i18nc("@title", "Open Url") + standardButtons: Kirigami.Dialog.Yes | Kirigami.Dialog.No + + contentItem: QQC2.Label { + text: i18nc("Do you want to open ", "Do you want to open %1?", root.link) + wrapMode: QQC2.Label.Wrap + horizontalAlignment: Qt.AlignHCenter + verticalAlignment: Qt.AlignVCenter + } + + onAccepted: { + Qt.openUrlExternally(root.link); + root.close(); + } + onRejected: { + root.close(); + } +} diff --git a/src/qml/ExploreComponent.qml b/src/qml/ExploreComponent.qml index bff942504..f5b9054f7 100644 --- a/src/qml/ExploreComponent.qml +++ b/src/qml/ExploreComponent.qml @@ -68,6 +68,16 @@ RowLayout { } } + property Kirigami.Action scanAction: Kirigami.Action { + text: i18n("Scan a QR Code") + icon.name: "view-barcode-qr" + onTriggered: pageStack.pushDialogLayer(Qt.createComponent("org.kde.neochat", "QrScannerPage.qml"), { + connection: root.connection + }, { + title: i18nc("@title", "Scan a QR Code") + }) + } + /** * @brief Emitted when the text is changed in the search field. */ @@ -130,6 +140,9 @@ RowLayout { QQC2.MenuItem { action: spaceAction } + QQC2.MenuItem { + action: scanAction + } } } Component { @@ -177,6 +190,11 @@ RowLayout { onClicked: menuRoot.close() Layout.fillWidth: true } + Delegates.RoundedItemDelegate { + action: scanAction + onClicked: menuRoot.close() + Layout.fillWidth: true + } } } } diff --git a/src/qml/JoinRoomDialog.qml b/src/qml/JoinRoomDialog.qml new file mode 100644 index 000000000..1a632e151 --- /dev/null +++ b/src/qml/JoinRoomDialog.qml @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: 2024 Tobias Fella +// SPDX-License-Identifier: GPL-2.0-or-later + +import QtQuick +import QtQuick.Controls as QQC2 +import QtQuick.Layouts + +import org.kde.kirigami as Kirigami +import org.kde.kirigamiaddons.components as KirigamiComponents +import org.kde.kirigamiaddons.formcard as FormCard +import org.kde.prison + +import org.kde.neochat + +Kirigami.Dialog { + id: root + + property string room + property NeoChatConnection connection + + leftPadding: 0 + rightPadding: 0 + topPadding: 0 + bottomPadding: 0 + + standardButtons: Kirigami.Dialog.NoButton + + width: Math.min(applicationWindow().width, Kirigami.Units.gridUnit * 24) + title: i18nc("@title", "Join Room") + + contentItem: ColumnLayout { + spacing: 0 + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: Kirigami.Units.largeSpacing + Layout.rightMargin: Kirigami.Units.largeSpacing + Layout.topMargin: Kirigami.Units.largeSpacing + Layout.bottomMargin: Kirigami.Units.largeSpacing + spacing: Kirigami.Units.largeSpacing + + KirigamiComponents.Avatar { + id: avatar + Layout.preferredWidth: Kirigami.Units.iconSizes.huge + Layout.preferredHeight: Kirigami.Units.iconSizes.huge + + name: root.room.slice(1, -1) + initialsMode: KirigamiComponents.Avatar.UseInitials + } + + Kirigami.Heading { + level: 1 + Layout.fillWidth: true + font.bold: true + + elide: Text.ElideRight + wrapMode: Text.NoWrap + text: root.room + textFormat: Text.PlainText + } + } + + Kirigami.Separator { + Layout.fillWidth: true + } + + FormCard.FormButtonDelegate { + text: i18nc("@action:button", "Join room") + icon.name: "irc-join-channel" + onClicked: { + RoomManager.resolveResource(root.room, "join"); + root.close(); + } + } + } +} diff --git a/src/qml/QrScannerPage.qml b/src/qml/QrScannerPage.qml new file mode 100644 index 000000000..14157dbd9 --- /dev/null +++ b/src/qml/QrScannerPage.qml @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: 2024 Tobias Fella +// SPDX-License-Identifier: GPL-2.0-or-later + +import QtQuick +import QtQuick.Controls as QQC2 +import QtMultimedia + +import org.kde.kirigami as Kirigami +import org.kde.prison.scanner as Prison + +import org.kde.neochat + +Kirigami.Page { + id: root + + title: i18nc("@title", "Scan a QR Code") + + required property NeoChatConnection connection + padding: 0 + + Component.onCompleted: camera.start() + + Connections { + target: root.QQC2.ApplicationWindow.window + function onClosing() { + root.destroy(); + } + } + + VideoOutput { + id: viewFinder + anchors.centerIn: parent + } + + Prison.VideoScanner { + id: scanner + property string previousText: "" + formats: Prison.Format.QRCode | Prison.Format.Aztec + onResultChanged: { + if (result.text.length > 0 && result.text != scanner.previousText) { + RoomManager.resolveResource(result.text, ""); + scanner.previousText = result.text; + } + root.closeDialog(); + } + videoSink: viewFinder.videoSink + } + + CaptureSession { + camera: Camera { + id: camera + } + imageCapture: ImageCapture { + id: imageCapture + } + videoOutput: viewFinder + } +} diff --git a/src/qml/RoomPage.qml b/src/qml/RoomPage.qml index 5093a0bcf..09382355a 100644 --- a/src/qml/RoomPage.qml +++ b/src/qml/RoomPage.qml @@ -238,9 +238,6 @@ Kirigami.Page { Connections { target: RoomManager - function onShowUserDetail(user) { - root.showUserDetail(user); - } function onShowEventSource(eventId) { applicationWindow().pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'MessageSourceSheet.qml'), { @@ -286,18 +283,6 @@ Kirigami.Page { } } - function showUserDetail(user) { - userDetailDialog.createObject(QQC2.ApplicationWindow.overlay, { - room: root.currentRoom, - user: root.currentRoom.getUser(user.id) - }).open(); - } - - Component { - id: userDetailDialog - UserDetailDialog {} - } - Component { id: messageDelegateContextMenu MessageDelegateContextMenu { diff --git a/src/qml/UserDetailDialog.qml b/src/qml/UserDetailDialog.qml index 39ace40d9..b7b2bd1a9 100644 --- a/src/qml/UserDetailDialog.qml +++ b/src/qml/UserDetailDialog.qml @@ -16,9 +16,13 @@ import org.kde.neochat Kirigami.Dialog { id: root + // This dialog is sometimes used outside the context of a room, e.g., when scanning a user's QR code. + // Make sure that code is prepared to deal with this property being null property NeoChatRoom room property var user + property NeoChatConnection connection + parent: applicationWindow().overlay leftPadding: 0 @@ -102,19 +106,19 @@ Kirigami.Dialog { } FormCard.FormButtonDelegate { - visible: !root.user.isLocalUser + visible: !root.user.isLocalUser && !!root.user.object action: Kirigami.Action { - text: room.connection.isIgnored(root.user.object) ? i18n("Unignore this user") : i18n("Ignore this user") + text: !!root.user.object && root.connection.isIgnored(root.user.object) ? i18n("Unignore this user") : i18n("Ignore this user") icon.name: "im-invisible-user" onTriggered: { root.close(); - room.connection.isIgnored(root.user.object) ? room.connection.removeFromIgnoredUsers(root.user.object) : room.connection.addToIgnoredUsers(root.user.object); + root.connection.isIgnored(root.user.object) ? root.connection.removeFromIgnoredUsers(root.user.object) : root.connection.addToIgnoredUsers(root.user.object); } } } FormCard.FormButtonDelegate { - visible: !root.user.isLocalUser && room.canSendState("kick") && room.containsUser(root.user.id) && room.getUserPowerLevel(root.user.id) < room.getUserPowerLevel(root.room.connection.localUser.id) + visible: root.room && !root.user.isLocalUser && room.canSendState("kick") && room.containsUser(root.user.id) && room.getUserPowerLevel(root.user.id) < room.getUserPowerLevel(root.connection.localUser.id) action: Kirigami.Action { text: i18n("Kick this user") @@ -127,10 +131,10 @@ Kirigami.Dialog { } FormCard.FormButtonDelegate { - visible: !root.user.isLocalUser && room.canSendState("invite") && !room.containsUser(root.user.id) + visible: root.room && !root.user.isLocalUser && room.canSendState("invite") && !room.containsUser(root.user.id) action: Kirigami.Action { - enabled: !room.isUserBanned(root.user.id) + enabled: root.room && !root.room.isUserBanned(root.user.id) text: i18n("Invite this user") icon.name: "list-add-user" onTriggered: { @@ -141,7 +145,7 @@ Kirigami.Dialog { } FormCard.FormButtonDelegate { - visible: !root.user.isLocalUser && room.canSendState("ban") && !room.isUserBanned(root.user.id) && room.getUserPowerLevel(root.user.id) < room.getUserPowerLevel(root.room.connection.localUser.id) + visible: root.room && !root.user.isLocalUser && room.canSendState("ban") && !room.isUserBanned(root.user.id) && room.getUserPowerLevel(root.user.id) < room.getUserPowerLevel(root.room.connection.localUser.id) action: Kirigami.Action { text: i18n("Ban this user") @@ -161,7 +165,7 @@ Kirigami.Dialog { } FormCard.FormButtonDelegate { - visible: !root.user.isLocalUser && room.canSendState("ban") && room.isUserBanned(root.user.id) + visible: root.room && !root.user.isLocalUser && room.canSendState("ban") && room.isUserBanned(root.user.id) action: Kirigami.Action { text: i18n("Unban this user") @@ -175,7 +179,7 @@ Kirigami.Dialog { } FormCard.FormButtonDelegate { - visible: room.canSendState("m.room.power_levels") + visible: root.room && room.canSendState("m.room.power_levels") action: Kirigami.Action { text: i18n("Set user power level") icon.name: "visibility" @@ -199,7 +203,7 @@ Kirigami.Dialog { } FormCard.FormButtonDelegate { - visible: root.user.isLocalUser || room.canSendState("redact") + visible: root.room && (root.user.isLocalUser || room.canSendState("redact")) action: Kirigami.Action { text: i18n("Remove recent messages by this user") @@ -221,10 +225,10 @@ Kirigami.Dialog { FormCard.FormButtonDelegate { visible: !root.user.isLocalUser action: Kirigami.Action { - text: root.room.connection.directChatExists(root.user.object) ? i18nc("%1 is the name of the user.", "Chat with %1", root.user.escapedDisplayName) : i18n("Invite to private chat") + text: root.connection.directChatExists(root.user.object) ? i18nc("%1 is the name of the user.", "Chat with %1", root.user.escapedDisplayName) : i18n("Invite to private chat") icon.name: "document-send" onTriggered: { - root.room.connection.openOrCreateDirectChat(root.user.object); + root.connection.openOrCreateDirectChat(root.user.object); root.close(); } } diff --git a/src/qml/main.qml b/src/qml/main.qml index a2f7bd884..44ebbbb4b 100644 --- a/src/qml/main.qml +++ b/src/qml/main.qml @@ -136,6 +136,17 @@ Kirigami.ApplicationWindow { } } + function onAskJoinRoom(room) { + joinRoomDialog.createObject(applicationWindow(), { + room: room, + connection: root.connection + }).open(); + } + + function onShowUserDetail(user) { + root.showUserDetail(user); + } + function onPushSpaceHome(room) { root.spaceHomePage = pageStack.push(Qt.createComponent('org.kde.neochat', 'SpaceHomePage.qml')); root.spaceHomePage.forceActiveFocus(); @@ -189,6 +200,11 @@ Kirigami.ApplicationWindow { user: user }).open(); } + function onExternalUrl(url) { + let dialog = Qt.createComponent("org.kde.neochat", "ConfirmUrlDialog.qml").createObject(applicationWindow()); + dialog.link = url; + dialog.open(); + } } function pushReplaceLayer(page, args) { @@ -404,6 +420,11 @@ Kirigami.ApplicationWindow { RoomWindow {} } + Component { + id: joinRoomDialog + JoinRoomDialog {} + } + Component { id: askDirectChatConfirmationComponent @@ -481,4 +502,16 @@ Kirigami.ApplicationWindow { dialog.closeDialog() }) } + function showUserDetail(user) { + userDetailDialog.createObject(root.QQC2.ApplicationWindow.window, { + room: RoomManager.currentRoom ? RoomManager.currentRoom : null, + user: RoomManager.currentRoom ? RoomManager.currentRoom.getUser(user.id) : QmlUtils.getUser(user), + connection: root.connection + }).open(); + } + + Component { + id: userDetailDialog + UserDetailDialog {} + } } diff --git a/src/roommanager.cpp b/src/roommanager.cpp index 8ccd60223..b36e184ae 100644 --- a/src/roommanager.cpp +++ b/src/roommanager.cpp @@ -99,7 +99,9 @@ void RoomManager::resolveResource(const QString &idOrUri, const QString &action) const auto result = visitResource(m_connection, uri); if (result == Quotient::CouldNotResolve) { - Q_EMIT warning(i18n("Room not found"), i18n("There's no room %1 in the room list. Check the spelling and the account.", idOrUri)); + if (uri.type() == Uri::RoomAlias || uri.type() == Uri::RoomId) { + Q_EMIT askJoinRoom(uri.primaryId()); + } } else { // Invalid cases should have been eliminated earlier Q_ASSERT(result == Quotient::UriResolved); @@ -355,19 +357,7 @@ void RoomManager::knockRoom(Quotient::Connection *account, const QString &roomAl bool RoomManager::visitNonMatrix(const QUrl &url) { -#ifdef Q_OS_ANDROID - if (!QDesktopServices::openUrl(url)) { - Q_EMIT warning(i18n("No application for the link"), i18n("Your operating system could not find an application for the link.")); - } -#else - auto *job = new KIO::OpenUrlJob(url); - connect(job, &KJob::finished, this, [this](KJob *job) { - if (job->error()) { - Q_EMIT warning(i18n("Could not open URL"), job->errorString()); - } - }); - job->start(); -#endif + Q_EMIT externalUrl(url); return true; } diff --git a/src/roommanager.h b/src/roommanager.h index c9107d9c4..e2a965b0a 100644 --- a/src/roommanager.h +++ b/src/roommanager.h @@ -215,6 +215,9 @@ public: void setConnection(NeoChatConnection *connection); Q_SIGNALS: + /** Ask the user whether the room should be joined. */ + void askJoinRoom(const QString &nameOrId); + void currentRoomChanged(); /** @@ -337,6 +340,8 @@ Q_SIGNALS: void directChatsActiveChanged(); void lastSpaceIdChanged(); + void externalUrl(const QUrl &url); + private: void openRoomForActiveConnection(); diff --git a/src/utils.cpp b/src/utils.cpp new file mode 100644 index 000000000..322596fd7 --- /dev/null +++ b/src/utils.cpp @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2024 Tobias Fella +// SPDX-License-Identifier: LGPL-2.0-or-later + +#include "utils.h" + +using namespace Quotient; + +static const QVariantMap emptyUser = { + {"isLocalUser"_ls, false}, + {"id"_ls, QString()}, + {"displayName"_ls, QString()}, + {"avatarSource"_ls, QUrl()}, + {"avatarMediaId"_ls, QString()}, + {"color"_ls, QColor()}, + {"object"_ls, QVariant()}, +}; + +QVariantMap QmlUtils::getUser(User *user) const +{ + if (user == nullptr) { + return emptyUser; + } + + const auto &url = user->avatarUrl(); + if (url.isEmpty() || url.scheme() != "mxc"_ls) { + return {}; + } + auto avatarSource = user->connection()->makeMediaUrl(url); + if (!avatarSource.isValid() || avatarSource.scheme() != QStringLiteral("mxc")) { + avatarSource = {}; + } + + return QVariantMap{ + {QStringLiteral("isLocalUser"), user->id() == user->connection()->user()->id()}, + {QStringLiteral("id"), user->id()}, + {QStringLiteral("displayName"), user->displayname()}, + {QStringLiteral("escapedDisplayName"), user->displayname().toHtmlEscaped()}, + {QStringLiteral("avatarSource"), avatarSource}, + {QStringLiteral("avatarMediaId"), user->avatarMediaId()}, + {QStringLiteral("color"), Utils::getUserColor(user->hueF())}, + {QStringLiteral("object"), QVariant::fromValue(user)}, + }; +} diff --git a/src/utils.h b/src/utils.h index 89af92b50..89251fecb 100644 --- a/src/utils.h +++ b/src/utils.h @@ -4,8 +4,37 @@ #include #include #include +#include #include +#include +#include + +class QmlUtils : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + +public: + static QmlUtils *create(QQmlEngine *, QJSEngine *) + { + QQmlEngine::setObjectOwnership(&instance(), QQmlEngine::CppOwnership); + return &instance(); + } + + static QmlUtils &instance() + { + static QmlUtils _instance; + return _instance; + } + + Q_INVOKABLE QVariantMap getUser(Quotient::User *user) const; + +private: + QmlUtils() = default; +}; + namespace Utils {