summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--CMakeLists.txt2
-rw-r--r--include/Config.h5
-rw-r--r--include/SuggestionsPopup.hpp63
-rw-r--r--include/TextInputWidget.h40
-rw-r--r--include/Utils.h4
-rw-r--r--resources/styles/nheko-dark.qss5
-rw-r--r--resources/styles/nheko.qss5
-rw-r--r--resources/styles/system.qss5
-rw-r--r--src/ChatPage.cc11
-rw-r--r--src/SuggestionsPopup.cpp105
-rw-r--r--src/TextInputWidget.cc139
-rw-r--r--src/Utils.cc28
12 files changed, 412 insertions, 0 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 5683fb0d..020049e1 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -200,6 +200,7 @@ set(SRC_FILES
     src/RunGuard.cc
     src/SideBarActions.cc
     src/Splitter.cc
+    src/SuggestionsPopup.cpp
     src/TextInputWidget.cc
     src/TopRoomBar.cc
     src/TrayIcon.cc
@@ -296,6 +297,7 @@ qt5_wrap_cpp(MOC_HEADERS
     include/RoomList.h
     include/SideBarActions.h
     include/Splitter.h
+    include/SuggestionsPopup.hpp
     include/TextInputWidget.h
     include/TopRoomBar.h
     include/TrayIcon.h
diff --git a/include/Config.h b/include/Config.h
index 54b2aa61..d7021d92 100644
--- a/include/Config.h
+++ b/include/Config.h
@@ -15,6 +15,11 @@ static constexpr int emojiSize                  = 14;
 static constexpr int headerFontSize             = 21;
 static constexpr int typingNotificationFontSize = 11;
 
+namespace popup {
+static constexpr int font   = fontSize;
+static constexpr int avatar = 28;
+}
+
 namespace receipts {
 static constexpr int font = 12;
 }
diff --git a/include/SuggestionsPopup.hpp b/include/SuggestionsPopup.hpp
new file mode 100644
index 00000000..23549124
--- /dev/null
+++ b/include/SuggestionsPopup.hpp
@@ -0,0 +1,63 @@
+#pragma once
+
+#include <QHBoxLayout>
+#include <QLabel>
+#include <QPoint>
+#include <QWidget>
+
+class Avatar;
+
+struct SearchResult
+{
+        QString user_id;
+        QString display_name;
+};
+
+Q_DECLARE_METATYPE(SearchResult)
+Q_DECLARE_METATYPE(QVector<SearchResult>)
+
+class PopupItem : public QWidget
+{
+        Q_OBJECT
+
+        Q_PROPERTY(QColor hoverColor READ hoverColor WRITE setHoverColor)
+
+public:
+        PopupItem(QWidget *parent, const QString &user_id);
+
+        QColor hoverColor() const { return hoverColor_; }
+        void setHoverColor(QColor &color) { hoverColor_ = color; }
+
+protected:
+        void paintEvent(QPaintEvent *event) override;
+        void mousePressEvent(QMouseEvent *event) override;
+
+signals:
+        void clicked(const QString &display_name);
+
+private:
+        QHBoxLayout *topLayout_;
+
+        Avatar *avatar_;
+        QLabel *userName_;
+        QString user_id_;
+
+        QColor hoverColor_;
+};
+
+class SuggestionsPopup : public QWidget
+{
+        Q_OBJECT
+
+public:
+        explicit SuggestionsPopup(QWidget *parent = nullptr);
+
+public slots:
+        void addUsers(const QVector<SearchResult> &users);
+
+signals:
+        void itemSelected(const QString &user);
+
+private:
+        QVBoxLayout *layout_;
+};
diff --git a/include/TextInputWidget.h b/include/TextInputWidget.h
index 872773f1..95262722 100644
--- a/include/TextInputWidget.h
+++ b/include/TextInputWidget.h
@@ -18,7 +18,11 @@
 #pragma once
 
 #include <deque>
+#include <iterator>
+#include <map>
 
+#include <QApplication>
+#include <QDebug>
 #include <QHBoxLayout>
 #include <QPaintEvent>
 #include <QTextEdit>
@@ -26,15 +30,20 @@
 
 #include "FlatButton.h"
 #include "LoadingIndicator.h"
+#include "SuggestionsPopup.hpp"
 
 #include "dialogs/PreviewUploadOverlay.h"
 
 #include "emoji/PickButton.h"
 
+class RoomState;
+
 namespace dialogs {
 class PreviewUploadOverlay;
 }
 
+struct SearchResult;
+
 class FilteredTextEdit : public QTextEdit
 {
         Q_OBJECT
@@ -61,18 +70,45 @@ signals:
         void video(QSharedPointer<QIODevice> data, const QString &filename);
         void file(QSharedPointer<QIODevice> data, const QString &filename);
 
+        //! Trigger the suggestion popup.
+        void showSuggestions(const QString &query);
+        void resultsRetrieved(const QVector<SearchResult> &results);
+
+public slots:
+        void showResults(const QVector<SearchResult> &results);
+
 protected:
         void keyPressEvent(QKeyEvent *event) override;
         bool canInsertFromMimeData(const QMimeData *source) const override;
         void insertFromMimeData(const QMimeData *source) override;
+        void focusOutEvent(QFocusEvent *event) override
+        {
+                popup_.hide();
+                QWidget::focusOutEvent(event);
+        }
 
 private:
         std::deque<QString> true_history_, working_history_;
         size_t history_index_;
         QTimer *typingTimer_;
 
+        SuggestionsPopup popup_;
+
+        void closeSuggestions() { popup_.hide(); }
+        void resetAnchor() { atTriggerPosition_ = -1; }
+
+        QString query()
+        {
+                auto cursor = textCursor();
+                cursor.movePosition(QTextCursor::StartOfWord, QTextCursor::KeepAnchor);
+                return cursor.selectedText();
+        }
+
         dialogs::PreviewUploadOverlay previewDialog_;
 
+        //! Latest position of the '@' character that triggers the username completer.
+        int atTriggerPosition_ = -1;
+
         void textChanged();
         void uploadData(const QByteArray data, const QString &media, const QString &filename);
         void afterCompletion(int);
@@ -97,6 +133,7 @@ public slots:
         void openFileSelection();
         void hideUploadSpinner();
         void focusLineEdit() { input_->setFocus(); }
+        void setRoomState(QSharedPointer<RoomState> state) { currState_ = state; }
 
 private slots:
         void addSelectedEmoji(const QString &emoji);
@@ -132,5 +169,8 @@ private:
         FlatButton *sendMessageBtn_;
         emoji::PickButton *emojiBtn_;
 
+        //! State of the current room.
+        QSharedPointer<RoomState> currState_;
+
         QColor borderColor_;
 };
diff --git a/include/Utils.h b/include/Utils.h
index fba9bf67..cbecb4ac 100644
--- a/include/Utils.h
+++ b/include/Utils.h
@@ -54,4 +54,8 @@ scaleDown(uint64_t max_width, uint64_t max_height, const ImageType &source)
         return source.scaled(
           final_width, final_height, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
 }
+
+//! Calculate the Levenshtein distance between two strings with character skipping.
+int
+levenshtein_distance(const std::string &s1, const std::string &s2);
 }
diff --git a/resources/styles/nheko-dark.qss b/resources/styles/nheko-dark.qss
index 034728f3..61643710 100644
--- a/resources/styles/nheko-dark.qss
+++ b/resources/styles/nheko-dark.qss
@@ -22,6 +22,11 @@ QuickSwitcher {
     background-color: #202228;
 }
 
+PopupItem {
+    background-color: #202228;
+    qproperty-hoverColor: rgba(45, 49, 57, 120);
+}
+
 RoomList,
 RoomList > * {
     background-color: #2d3139;
diff --git a/resources/styles/nheko.qss b/resources/styles/nheko.qss
index e18704b5..b028c7d6 100644
--- a/resources/styles/nheko.qss
+++ b/resources/styles/nheko.qss
@@ -22,6 +22,11 @@ QuickSwitcher {
     background-color: white;
 }
 
+PopupItem {
+    background-color: white;
+    qproperty-hoverColor: rgba(192, 193, 195, 120);
+}
+
 RoomList,
 RoomList > * {
     background-color: white;
diff --git a/resources/styles/system.qss b/resources/styles/system.qss
index 60b8865a..ce63f44e 100644
--- a/resources/styles/system.qss
+++ b/resources/styles/system.qss
@@ -25,6 +25,11 @@ QuickSwitcher {
     background-color: palette(window);
 }
 
+PopupItem {
+    background-color: palette(window);
+    qproperty-hoverColor: rgba(192, 193, 195, 120);
+}
+
 FlatButton {
     qproperty-foregroundColor: palette(text);
 }
diff --git a/src/ChatPage.cc b/src/ChatPage.cc
index f2a3e269..b49fb6a2 100644
--- a/src/ChatPage.cc
+++ b/src/ChatPage.cc
@@ -158,6 +158,12 @@ ChatPage::ChatPage(QSharedPointer<MatrixClient> client,
                 typingDisplay_->setUsers(users);
         });
         connect(room_list_, &RoomList::roomChanged, text_input_, &TextInputWidget::stopTyping);
+        connect(room_list_, &RoomList::roomChanged, text_input_, [this](const QString &room_id) {
+                if (roomStates_.find(room_id) != roomStates_.end())
+                        text_input_->setRoomState(roomStates_[room_id]);
+                else
+                        qWarning() << "no state found for room_id" << room_id;
+        });
 
         connect(room_list_, &RoomList::roomChanged, this, &ChatPage::changeTopRoomInfo);
         connect(room_list_, &RoomList::roomChanged, text_input_, &TextInputWidget::focusLineEdit);
@@ -781,6 +787,11 @@ ChatPage::updateTypingUsers(const QString &roomid, const std::vector<std::string
         if (!userSettings_->isTypingNotificationsEnabled())
                 return;
 
+        if (user_ids.empty()) {
+                typingUsers_[roomid] = {};
+                return;
+        }
+
         QStringList users;
 
         QSettings settings;
diff --git a/src/SuggestionsPopup.cpp b/src/SuggestionsPopup.cpp
new file mode 100644
index 00000000..3a7b3852
--- /dev/null
+++ b/src/SuggestionsPopup.cpp
@@ -0,0 +1,105 @@
+#include "Avatar.h"
+#include "AvatarProvider.h"
+#include "Config.h"
+#include "DropShadow.h"
+#include "SuggestionsPopup.hpp"
+#include "Utils.h"
+#include "timeline/TimelineViewManager.h"
+
+#include <QDebug>
+#include <QPaintEvent>
+#include <QPainter>
+#include <QStyleOption>
+
+constexpr int PopupHMargin    = 5;
+constexpr int PopupItemMargin = 4;
+
+PopupItem::PopupItem(QWidget *parent, const QString &user_id)
+  : QWidget(parent)
+  , avatar_{new Avatar(this)}
+  , user_id_{user_id}
+{
+        setMouseTracking(true);
+        setAttribute(Qt::WA_Hover);
+
+        topLayout_ = new QHBoxLayout(this);
+        topLayout_->setContentsMargins(
+          PopupHMargin, PopupItemMargin, PopupHMargin, PopupItemMargin);
+
+        QFont font;
+        font.setPixelSize(conf::popup::font);
+
+        auto displayName = TimelineViewManager::displayName(user_id);
+
+        avatar_->setSize(conf::popup::avatar);
+        avatar_->setLetter(utils::firstChar(displayName));
+
+        // If it's a matrix id we use the second letter.
+        if (displayName.size() > 1 && displayName.at(0) == '@')
+                avatar_->setLetter(QChar(displayName.at(1)));
+
+        userName_ = new QLabel(displayName, this);
+        userName_->setFont(font);
+
+        topLayout_->addWidget(avatar_);
+        topLayout_->addWidget(userName_, 1);
+
+        /* AvatarProvider::resolve(user_id, [this](const QImage &img) { avatar_->setImage(img); });
+         */
+}
+
+void
+PopupItem::paintEvent(QPaintEvent *)
+{
+        QStyleOption opt;
+        opt.init(this);
+        QPainter p(this);
+        style()->drawPrimitive(QStyle::PE_Widget, &opt, &p, this);
+
+        if (underMouse())
+                p.fillRect(rect(), hoverColor_);
+}
+
+void
+PopupItem::mousePressEvent(QMouseEvent *event)
+{
+        if (event->buttons() != Qt::RightButton)
+                emit clicked(TimelineViewManager::displayName(user_id_));
+
+        QWidget::mousePressEvent(event);
+}
+
+SuggestionsPopup::SuggestionsPopup(QWidget *parent)
+  : QWidget(parent)
+{
+        setAttribute(Qt::WA_ShowWithoutActivating, true);
+        setWindowFlags(Qt::ToolTip | Qt::NoDropShadowWindowHint);
+
+        layout_ = new QVBoxLayout(this);
+        layout_->setMargin(0);
+        layout_->setSpacing(0);
+}
+
+void
+SuggestionsPopup::addUsers(const QVector<SearchResult> &users)
+{
+        // Remove all items from the layout.
+        QLayoutItem *item;
+        while ((item = layout_->takeAt(0)) != 0) {
+                delete item->widget();
+                delete item;
+        }
+
+        if (users.isEmpty()) {
+                hide();
+                return;
+        }
+
+        for (const auto &u : users) {
+                auto user = new PopupItem(this, u.user_id);
+                layout_->addWidget(user);
+                connect(user, &PopupItem::clicked, this, &SuggestionsPopup::itemSelected);
+        }
+
+        resize(geometry().width(), 40 * users.size());
+}
diff --git a/src/TextInputWidget.cc b/src/TextInputWidget.cc
index 3f3d5cd9..e184d8b4 100644
--- a/src/TextInputWidget.cc
+++ b/src/TextInputWidget.cc
@@ -15,6 +15,8 @@
  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
  */
 
+#include <thread>
+
 #include <QAbstractTextDocumentLayout>
 #include <QApplication>
 #include <QBuffer>
@@ -28,17 +30,23 @@
 #include <QPainter>
 #include <QStyleOption>
 
+#include <variant.hpp>
+
 #include "Config.h"
+#include "RoomState.h"
 #include "TextInputWidget.h"
+#include "Utils.h"
 
 static constexpr size_t INPUT_HISTORY_SIZE = 127;
 static constexpr int MAX_TEXTINPUT_HEIGHT  = 120;
 static constexpr int InputHeight           = 26;
 static constexpr int ButtonHeight          = 24;
+static constexpr int MaxPopupItems         = 5;
 
 FilteredTextEdit::FilteredTextEdit(QWidget *parent)
   : QTextEdit{parent}
   , history_index_{0}
+  , popup_{parent}
   , previewDialog_{parent}
 {
         setFrameStyle(QFrame::NoFrame);
@@ -64,10 +72,44 @@ FilteredTextEdit::FilteredTextEdit(QWidget *parent)
                 this,
                 &FilteredTextEdit::uploadData);
 
+        qRegisterMetaType<SearchResult>();
+        qRegisterMetaType<QVector<SearchResult>>();
+        connect(this, &FilteredTextEdit::resultsRetrieved, this, &FilteredTextEdit::showResults);
+        connect(&popup_, &SuggestionsPopup::itemSelected, this, [this](const QString &text) {
+                popup_.hide();
+
+                auto cursor   = textCursor();
+                const int end = cursor.position();
+
+                cursor.setPosition(atTriggerPosition_, QTextCursor::MoveAnchor);
+                cursor.setPosition(end, QTextCursor::KeepAnchor);
+                cursor.removeSelectedText();
+                cursor.insertText(text);
+        });
+
         previewDialog_.hide();
 }
 
 void
+FilteredTextEdit::showResults(const QVector<SearchResult> &results)
+{
+        QPoint pos;
+
+        if (atTriggerPosition_ != -1) {
+                auto cursor = textCursor();
+                cursor.setPosition(atTriggerPosition_);
+                pos = viewport()->mapToGlobal(cursorRect(cursor).topLeft());
+        } else {
+                auto rect = cursorRect();
+                pos       = viewport()->mapToGlobal(rect.topLeft());
+        }
+
+        popup_.addUsers(results);
+        popup_.move(pos.x(), pos.y() - popup_.height() - 10);
+        popup_.show();
+}
+
+void
 FilteredTextEdit::keyPressEvent(QKeyEvent *event)
 {
         const bool isModifier = (event->modifiers() != Qt::NoModifier);
@@ -79,7 +121,34 @@ FilteredTextEdit::keyPressEvent(QKeyEvent *event)
                 typingTimer_->start();
         }
 
+        // calculate the new query
+        if (textCursor().position() < atTriggerPosition_ || atTriggerPosition_ == -1) {
+                resetAnchor();
+                closeSuggestions();
+        }
+
+        if (popup_.isVisible()) {
+                switch (event->key()) {
+                case Qt::Key_Enter:
+                case Qt::Key_Return:
+                case Qt::Key_Escape:
+                case Qt::Key_Tab:
+                case Qt::Key_Space:
+                case Qt::Key_Backtab: {
+                        closeSuggestions();
+                        break;
+                }
+                default:
+                        break;
+                }
+        }
+
         switch (event->key()) {
+        case Qt::Key_At:
+                atTriggerPosition_ = textCursor().position();
+
+                QTextEdit::keyPressEvent(event);
+                break;
         case Qt::Key_Return:
         case Qt::Key_Enter:
                 if (!(event->modifiers() & Qt::ShiftModifier)) {
@@ -124,6 +193,30 @@ FilteredTextEdit::keyPressEvent(QKeyEvent *event)
         }
         default:
                 QTextEdit::keyPressEvent(event);
+
+                // Check if the current word should be autocompleted.
+                auto cursor = textCursor();
+                cursor.movePosition(QTextCursor::StartOfWord, QTextCursor::KeepAnchor);
+                auto word = cursor.selectedText();
+
+                if (cursor.position() == 0) {
+                        closeSuggestions();
+                        return;
+                }
+
+                if (cursor.position() == atTriggerPosition_ + 1) {
+                        const auto q = query();
+
+                        if (q.isEmpty()) {
+                                closeSuggestions();
+                                return;
+                        }
+
+                        emit showSuggestions(query());
+                } else {
+                        closeSuggestions();
+                }
+
                 break;
         }
 }
@@ -340,6 +433,52 @@ TextInputWidget::TextInputWidget(QWidget *parent)
                 setFixedHeight(widgetHeight);
                 input_->setFixedHeight(textInputHeight);
         });
+        connect(input_, &FilteredTextEdit::showSuggestions, this, [this](const QString &q) {
+                if (q.isEmpty() || currState_.isNull())
+                        return;
+
+                std::thread worker([this, q = q.toLower().toStdString()]() {
+                        std::multimap<int, std::pair<std::string, std::string>> items;
+
+                        auto get_name = [](auto membership) {
+                                auto name = membership.second.content.display_name;
+                                auto key  = membership.first;
+
+                                // Remove the leading '@' character.
+                                if (name.empty()) {
+                                        key.erase(0, 1);
+                                        name = key;
+                                }
+
+                                return std::make_pair(key, name);
+                        };
+
+                        for (const auto &m : currState_->memberships) {
+                                const auto user = get_name(m);
+                                const int score = utils::levenshtein_distance(q, user.second);
+
+                                items.emplace(score, user);
+                        }
+
+                        QVector<SearchResult> results;
+                        auto end = items.begin();
+
+                        if (items.size() >= MaxPopupItems)
+                                std::advance(end, MaxPopupItems);
+
+                        for (auto it = items.begin(); it != end; it++) {
+                                const auto user = it->second;
+
+                                results.push_back(
+                                  SearchResult{QString::fromStdString(user.first),
+                                               QString::fromStdString(user.second)});
+                        }
+
+                        emit input_->resultsRetrieved(results);
+                });
+
+                worker.detach();
+        });
 
         sendMessageBtn_ = new FlatButton(this);
 
diff --git a/src/Utils.cc b/src/Utils.cc
index 6f438c20..169be75e 100644
--- a/src/Utils.cc
+++ b/src/Utils.cc
@@ -149,3 +149,31 @@ utils::humanReadableFileSize(uint64_t bytes)
 
         return QString::number(size, 'g', 4) + ' ' + units[u];
 }
+
+int
+utils::levenshtein_distance(const std::string &s1, const std::string &s2)
+{
+        const int nlen = s1.size();
+        const int hlen = s2.size();
+
+        if (hlen == 0)
+                return -1;
+        if (nlen == 1)
+                return s2.find(s1);
+
+        std::vector<int> row1(hlen + 1, 0);
+
+        for (int i = 0; i < nlen; ++i) {
+                std::vector<int> row2(1, i + 1);
+
+                for (int j = 0; j < hlen; ++j) {
+                        const int cost = s1[i] != s2[j];
+                        row2.push_back(
+                          std::min(row1[j + 1] + 1, std::min(row2[j] + 1, row1[j] + cost)));
+                }
+
+                row1.swap(row2);
+        }
+
+        return *std::min_element(row1.begin(), row1.end());
+}