diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 7857c25df..401d8af93 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -45,6 +45,7 @@ add_library(neochat STATIC serverlistmodel.cpp statemodel.cpp filetransferpseudojob.cpp + searchmodel.cpp ) add_executable(neochat-app diff --git a/src/main.cpp b/src/main.cpp index a8a7a47b2..ab4aa66d4 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -59,6 +59,7 @@ #include "neochatroom.h" #include "neochatuser.h" #include "notificationsmanager.h" +#include "searchmodel.h" #ifdef QUOTIENT_07 #include "pollhandler.h" #endif @@ -228,6 +229,7 @@ int main(int argc, char *argv[]) qmlRegisterType("org.kde.neochat", 1, 0, "LinkPreviewer"); qmlRegisterType("org.kde.neochat", 1, 0, "CompletionModel"); qmlRegisterType("org.kde.neochat", 1, 0, "StateModel"); + qmlRegisterType("org.kde.neochat", 1, 0, "SearchModel"); #ifdef QUOTIENT_07 qmlRegisterType("org.kde.neochat", 1, 0, "PollHandler"); #endif diff --git a/src/qml/Component/Timeline/TimelineContainer.qml b/src/qml/Component/Timeline/TimelineContainer.qml index 57682cc33..12be8185c 100644 --- a/src/qml/Component/Timeline/TimelineContainer.qml +++ b/src/qml/Component/Timeline/TimelineContainer.qml @@ -20,7 +20,7 @@ ColumnLayout { default property alias innerObject : column.children - property Item hoverComponent: hoverActions + property Item hoverComponent: hoverActions ?? null property bool isEmote: false property bool cardBackground: true property bool showUserMessageOnRight: Config.showLocalMessagesOnRight && model.author.isLocalUser && !Config.compactLayout @@ -106,6 +106,9 @@ ColumnLayout { // Show hover actions by updating the global hover component to this delegate function updateHoverComponent() { + if (!hoverComponent) { + return; + } if (hovered && !Kirigami.Settings.isMobile) { hoverComponent.delegate = root hoverComponent.bubble = bubble @@ -229,10 +232,10 @@ ColumnLayout { QQC2.Label { id: timeLabel - text: visible ? time.toLocaleTimeString(Qt.locale(), Locale.ShortFormat) : "" + text: visible ? model.time.toLocaleTimeString(Qt.locale(), Locale.ShortFormat) : "" color: Kirigami.Theme.disabledTextColor QQC2.ToolTip.visible: hoverHandler.hovered - QQC2.ToolTip.text: time.toLocaleString(Qt.locale(), Locale.LongFormat) + QQC2.ToolTip.text: model.time.toLocaleString(Qt.locale(), Locale.LongFormat) QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay HoverHandler { @@ -268,6 +271,7 @@ ColumnLayout { id: bubbleBackground visible: cardBackground && !Config.compactLayout anchors.fill: parent + Kirigami.Theme.colorSet: Kirigami.Theme.View color: { if (model.author.isLocalUser) { return Kirigami.ColorUtils.tintWithAlpha(Kirigami.Theme.backgroundColor, Kirigami.Theme.highlightColor, 0.15) diff --git a/src/qml/Page/SearchPage.qml b/src/qml/Page/SearchPage.qml new file mode 100644 index 000000000..bd758c790 --- /dev/null +++ b/src/qml/Page/SearchPage.qml @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: 2022 Tobias Fella +// SPDX-License-Identifier: GPL-2.0-or-later + +import QtQuick 2.15 +import QtQuick.Controls 2.15 as QQC2 +import QtQuick.Layouts 1.15 + +import org.kde.kirigami 2.20 as Kirigami + +import org.kde.neochat 1.0 + +Kirigami.ScrollablePage { + id: searchPage + + property var currentRoom + + title: i18nc("@action:title", "Search Messages") + + Kirigami.Theme.colorSet: Kirigami.Theme.Window + + SearchModel { + id: searchModel + connection: Controller.activeConnection + searchText: searchField.text + room: searchPage.currentRoom + } + + header: RowLayout { + Kirigami.SearchField { + id: searchField + Layout.topMargin: Kirigami.Units.smallSpacing + Layout.leftMargin: Kirigami.Units.smallSpacing + Layout.fillWidth: true + Keys.onEnterPressed: searchButton.clicked() + Keys.onReturnPressed: searchButton.clicked() + } + QQC2.Button { + id: searchButton + Layout.topMargin: Kirigami.Units.smallSpacing + Layout.rightMargin: Kirigami.Units.smallSpacing + onClicked: searchModel.search() + icon.name: "search" + } + } + + ListView { + id: messageListView + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 0 + verticalLayoutDirection: ListView.BottomToTop + + section.property: "section" + + Kirigami.PlaceholderMessage { + anchors.centerIn: parent + visible: searchField.text.length === 0 && messageListView.count === 0 + text: i18n("Enter a text to start searching") + } + + Kirigami.PlaceholderMessage { + anchors.centerIn: parent + visible: searchField.text.length > 0 && messageListView.count === 0 && !searchModel.searching + text: i18n("No results found") + } + + Kirigami.LoadingPlaceholder { + anchors.centerIn: parent + visible: searchModel.searching + } + + model: searchModel + delegate: EventDelegate {} + } +} diff --git a/src/qml/Panel/RoomDrawer.qml b/src/qml/Panel/RoomDrawer.qml index d2ae3d535..2c7170833 100644 --- a/src/qml/Panel/RoomDrawer.qml +++ b/src/qml/Panel/RoomDrawer.qml @@ -111,6 +111,23 @@ Kirigami.OverlayDrawer { text: devtoolsButton.text } } + QQC2.ToolButton { + id: searchButton + + Layout.alignment: Qt.AlignRight + icon.name: "search" + text: i18n("Search in this room") + display: QQC2.AbstractButton.IconOnly + visible: Controller.quotientMinorVersion > 6 + + onClicked: { + pageStack.pushDialogLayer("qrc:/SearchPage.qml", { + currentRoom: room + }, { + title: i18nc("@action:title", "Search") + }) + } + } QQC2.ToolButton { id: inviteButton diff --git a/src/res.qrc b/src/res.qrc index 5e23cc182..0bcaf6bbd 100644 --- a/src/res.qrc +++ b/src/res.qrc @@ -96,5 +96,6 @@ qml/Component/Emoji/EmojiTonesPicker.qml qml/Component/Emoji/EmojiDelegate.qml qml/Component/Emoji/EmojiGrid.qml + qml/Page/SearchPage.qml diff --git a/src/searchmodel.cpp b/src/searchmodel.cpp new file mode 100644 index 000000000..c6100defc --- /dev/null +++ b/src/searchmodel.cpp @@ -0,0 +1,180 @@ +// SPDX-FileCopyrightText: 2022 Tobias Fella +// SPDX-License-Identifier: LGPL-2.0-or-later + +#include "searchmodel.h" +#include "messageeventmodel.h" +#include "neochatroom.h" +#include "neochatuser.h" +#include +#include + +#ifdef QUOTIENT_07 +#include +#endif + +using namespace Quotient; + +// TODO search only in the current room + +SearchModel::SearchModel(QObject *parent) + : QAbstractListModel(parent) +{ +} + +QString SearchModel::searchText() const +{ + return m_searchText; +} + +void SearchModel::setSearchText(const QString &searchText) +{ + m_searchText = searchText; + Q_EMIT searchTextChanged(); +} + +void SearchModel::search() +{ +#ifdef QUOTIENT_07 + Q_ASSERT(m_connection); + setSearching(true); + if (m_job) { + m_job->abandon(); + m_job = nullptr; + } + + SearchJob::RoomEventsCriteria criteria{ + m_searchText, + {}, + RoomEventFilter{ + .rooms = {m_room->id()}, + }, + "recent", + SearchJob::IncludeEventContext{3, 3, true}, + false, + none, + }; + + auto job = m_connection->callApi(SearchJob::Categories{criteria}); + m_job = job; + connect(job, &BaseJob::finished, this, [=] { + beginResetModel(); + m_result = job->searchCategories().roomEvents; + endResetModel(); + setSearching(false); + m_job = nullptr; + // TODO error handling + }); +#endif +} + +Connection *SearchModel::connection() const +{ + return m_connection; +} + +void SearchModel::setConnection(Connection *connection) +{ + m_connection = connection; + Q_EMIT connectionChanged(); +} + +QVariant SearchModel::data(const QModelIndex &index, int role) const +{ +#ifdef QUOTIENT_07 + auto row = index.row(); + const auto &event = *m_result->results[row].result; + switch (role) { + case DisplayRole: + return m_room->eventToString(*m_result->results[row].result); + case ShowAuthorRole: + return true; + case AuthorRole: + return QVariantMap{ + {"isLocalUser", event.senderId() == m_room->localUser()->id()}, + {"id", event.senderId()}, + {"avatarMediaId", m_connection->user(event.senderId())->avatarMediaId(m_room)}, + {"avatarUrl", m_connection->user(event.senderId())->avatarUrl(m_room)}, + {"displayName", m_connection->user(event.senderId())->displayname(m_room)}, + {"display", m_connection->user(event.senderId())->name()}, + {"color", dynamic_cast(m_connection->user(event.senderId()))->color()}, + {"object", QVariant::fromValue(m_connection->user(event.senderId()))}, + }; + case ShowSectionRole: + if (row == 0) { + return true; + } + return event.originTimestamp().date() != m_result->results[row - 1].result->originTimestamp().date(); + case SectionRole: + return renderDate(event.originTimestamp()); + case TimeRole: + return event.originTimestamp(); + } + return MessageEventModel::DelegateType::Message; +#endif + return {}; +} + +int SearchModel::rowCount(const QModelIndex &parent) const +{ +#ifdef QUOTIENT_07 + if (m_result.has_value()) { + return m_result->results.size(); + } +#endif + return 0; +} + +QHash SearchModel::roleNames() const +{ + return { + {EventTypeRole, "eventType"}, + {DisplayRole, "display"}, + {AuthorRole, "author"}, + {ShowSectionRole, "showSection"}, + {SectionRole, "section"}, + {TimeRole, "time"}, + {ShowAuthorRole, "showAuthor"}, + }; +} + +NeoChatRoom *SearchModel::room() const +{ + return m_room; +} + +void SearchModel::setRoom(NeoChatRoom *room) +{ + m_room = room; + Q_EMIT roomChanged(); +} + +// TODO deduplicate with messageeventmodel +QString renderDate(const QDateTime ×tamp) +{ + auto date = timestamp.toLocalTime().date(); + if (date == QDate::currentDate()) { + return i18n("Today"); + } + if (date == QDate::currentDate().addDays(-1)) { + return i18n("Yesterday"); + } + if (date == QDate::currentDate().addDays(-2)) { + return i18n("The day before yesterday"); + } + if (date > QDate::currentDate().addDays(-7)) { + return date.toString("dddd"); + } + + return QLocale::system().toString(date, QLocale::ShortFormat); +} + +bool SearchModel::searching() const +{ + return m_searching; +} + +void SearchModel::setSearching(bool searching) +{ + m_searching = searching; + Q_EMIT searchingChanged(); +} diff --git a/src/searchmodel.h b/src/searchmodel.h new file mode 100644 index 000000000..00ebad1ae --- /dev/null +++ b/src/searchmodel.h @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: 2022 Tobias Fella +// SPDX-License-Identifier: LGPL-2.0-or-later + +#pragma once + +#include +#include + +#ifdef QUOTIENT_07 +#include +#endif + +namespace Quotient +{ +class Connection; +} + +class NeoChatRoom; + +class SearchModel : public QAbstractListModel +{ + Q_OBJECT + + Q_PROPERTY(QString searchText READ searchText WRITE setSearchText NOTIFY searchTextChanged) + Q_PROPERTY(Quotient::Connection *connection READ connection WRITE setConnection NOTIFY connectionChanged) + Q_PROPERTY(NeoChatRoom *room READ room WRITE setRoom NOTIFY roomChanged) + Q_PROPERTY(bool searching READ searching NOTIFY searchingChanged) + +public: + enum Roles { + DisplayRole = Qt::DisplayRole, + EventTypeRole, + ShowAuthorRole, + AuthorRole, + ShowSectionRole, + SectionRole, + TimeRole, + }; + Q_ENUM(Roles); + SearchModel(QObject *parent = nullptr); + + QString searchText() const; + void setSearchText(const QString &searchText); + + Quotient::Connection *connection() const; + void setConnection(Quotient::Connection *connection); + + NeoChatRoom *room() const; + void setRoom(NeoChatRoom *room); + + Q_INVOKABLE void search(); + + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QHash roleNames() const override; + + bool searching() const; + +Q_SIGNALS: + void searchTextChanged(); + void connectionChanged(); + void roomChanged(); + void searchingChanged(); + +private: + void setSearching(bool searching); + + QString m_searchText; + Quotient::Connection *m_connection = nullptr; + NeoChatRoom *m_room = nullptr; +#ifdef QUOTIENT_07 + Quotient::Omittable m_result = Quotient::none; + Quotient::SearchJob *m_job = nullptr; +#endif + bool m_searching = false; +}; + +QString renderDate(const QDateTime &dateTime);