diff --git a/resources/icons/ui/collapsed.svg b/resources/icons/ui/collapsed.svg
new file mode 100644
index 00000000..0ac6a30d
--- /dev/null
+++ b/resources/icons/ui/collapsed.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/resources/icons/ui/expanded.svg b/resources/icons/ui/expanded.svg
new file mode 100644
index 00000000..8c03304a
--- /dev/null
+++ b/resources/icons/ui/expanded.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/resources/qml/CommunitiesList.qml b/resources/qml/CommunitiesList.qml
index 0a8587b3..fab3316e 100644
--- a/resources/qml/CommunitiesList.qml
+++ b/resources/qml/CommunitiesList.qml
@@ -11,6 +11,7 @@ import QtQuick.Layouts 1.3
import im.nheko 1.0
Page {
+ id: communitySidebar
//leftPadding: Nheko.paddingSmall
//rightPadding: Nheko.paddingSmall
property int avatarSize: Math.ceil(fontMetrics.lineSpacing * 1.6)
@@ -22,7 +23,7 @@ Page {
anchors.left: parent.left
anchors.right: parent.right
height: parent.height
- model: Communities
+ model: Communities.filtered()
ScrollHelper {
flickable: parent
@@ -107,9 +108,31 @@ Page {
}
RowLayout {
+ id: r
spacing: Nheko.paddingMedium
anchors.fill: parent
anchors.margins: Nheko.paddingMedium
+ anchors.leftMargin: communitySidebar.collapsed ? Nheko.paddingMedium : (Nheko.paddingMedium * (model.depth + 1))
+
+ ImageButton {
+ visible: !communitySidebar.collapsed && model.collapsible
+ Layout.preferredHeight: fontMetrics.lineSpacing
+ Layout.preferredWidth: fontMetrics.lineSpacing
+ Layout.alignment: Qt.AlignVCenter
+ height: fontMetrics.lineSpacing
+ width: fontMetrics.lineSpacing
+ image: model.collapsed ? ":/icons/icons/ui/collapsed.svg" : ":/icons/icons/ui/expanded.svg"
+ ToolTip.visible: hovered
+ ToolTip.text: model.collapsed ? qsTr("Expand") : qsTr("Collapse")
+ hoverEnabled: true
+
+ onClicked: model.collapsed = !model.collapsed
+ }
+
+ Item {
+ Layout.preferredWidth: fontMetrics.lineSpacing
+ visible: !communitySidebar.collapsed && !model.collapsible
+ }
Avatar {
id: avatar
@@ -130,10 +153,10 @@ Page {
}
ElidedLabel {
- visible: !collapsed
+ visible: !communitySidebar.collapsed
Layout.alignment: Qt.AlignVCenter
color: communityItem.importantText
- elideWidth: parent.width - avatar.width - Nheko.paddingMedium
+ elideWidth: parent.width - avatar.width - r.anchors.leftMargin - Nheko.paddingMedium - fontMetrics.lineSpacing
fullText: model.displayName
textFormat: Text.PlainText
}
diff --git a/resources/res.qrc b/resources/res.qrc
index 4bde40a5..2ab60e3a 100644
--- a/resources/res.qrc
+++ b/resources/res.qrc
@@ -1,24 +1,29 @@
- icons/ui/sticky-note-solid.svg
icons/ui/add-square-button.svg
- icons/ui/send.svg
- icons/ui/smile.svg
- icons/ui/user-friends-solid.svg
- icons/ui/place-call.svg
- icons/ui/attach.svg
icons/ui/angle-arrow-left.svg
+ icons/ui/attach.svg
+ icons/ui/ban.svg
icons/ui/chat.svg
icons/ui/checkmark.svg
icons/ui/clock.svg
+ icons/ui/collapsed.svg
icons/ui/delete.svg
+ icons/ui/dismiss.svg
+ icons/ui/double-checkmark.svg
+ icons/ui/download.svg
icons/ui/edit.svg
icons/ui/end-call.svg
+ icons/ui/expanded.svg
+ icons/ui/image-failed.svg
icons/ui/lowprio.svg
icons/ui/microphone-mute.svg
icons/ui/microphone-unmute.svg
+ icons/ui/options.svg
icons/ui/pause-symbol.svg
icons/ui/people.svg
+ icons/ui/picture-in-picture.svg
+ icons/ui/place-call.svg
icons/ui/play-sign.svg
icons/ui/power-off.svg
icons/ui/refresh.svg
@@ -26,21 +31,18 @@
icons/ui/round-remove-button.svg
icons/ui/screen-share.svg
icons/ui/search.svg
+ icons/ui/send.svg
icons/ui/settings.svg
+ icons/ui/smile.svg
icons/ui/speech-bubbles.svg
icons/ui/star.svg
+ icons/ui/sticky-note-solid.svg
icons/ui/tag.svg
+ icons/ui/user-friends-solid.svg
icons/ui/video.svg
icons/ui/volume-off-indicator.svg
icons/ui/volume-up.svg
icons/ui/world.svg
- icons/ui/picture-in-picture.svg
- icons/ui/options.svg
- icons/ui/double-checkmark.svg
- icons/ui/ban.svg
- icons/ui/image-failed.svg
- icons/ui/dismiss.svg
- icons/ui/download.svg
icons/emoji-categories/activity.svg
icons/emoji-categories/flags.svg
icons/emoji-categories/foods.svg
diff --git a/src/Cache_p.h b/src/Cache_p.h
index a48588e1..6a6b4e0c 100644
--- a/src/Cache_p.h
+++ b/src/Cache_p.h
@@ -95,6 +95,12 @@ public:
auto txn = lmdb::txn::begin(env_, nullptr, MDB_RDONLY);
return getStateEvent(txn, room_id, state_key);
}
+ template
+ std::vector> getStateEventsWithType(const std::string &room_id)
+ {
+ auto txn = lmdb::txn::begin(env_, nullptr, MDB_RDONLY);
+ return getStateEventsWithType(txn, room_id);
+ }
//! retrieve a specific event from account data
//! pass empty room_id for global account data
diff --git a/src/timeline/CommunitiesModel.cpp b/src/timeline/CommunitiesModel.cpp
index 90f1532b..7b323bb9 100644
--- a/src/timeline/CommunitiesModel.cpp
+++ b/src/timeline/CommunitiesModel.cpp
@@ -7,6 +7,8 @@
#include
#include "Cache.h"
+#include "Cache_p.h"
+#include "Logging.h"
#include "UserSettingsPage.h"
CommunitiesModel::CommunitiesModel(QObject *parent)
@@ -20,12 +22,29 @@ CommunitiesModel::roleNames() const
{AvatarUrl, "avatarUrl"},
{DisplayName, "displayName"},
{Tooltip, "tooltip"},
- {ChildrenHidden, "childrenHidden"},
+ {Collapsed, "collapsed"},
+ {Collapsible, "collapsible"},
{Hidden, "hidden"},
+ {Depth, "depth"},
{Id, "id"},
};
}
+bool
+CommunitiesModel::setData(const QModelIndex &index, const QVariant &value, int role)
+{
+ if (role != CommunitiesModel::Collapsed)
+ return false;
+ else if (index.row() >= 2 || index.row() - 2 < spaceOrder_.size()) {
+ spaceOrder_.tree.at(index.row() - 2).collapsed = value.toBool();
+
+ const auto cindex = spaceOrder_.lastChild(index.row() - 2);
+ emit dataChanged(index, this->index(cindex + 2), {Collapsed, Qt::DisplayRole});
+ return true;
+ } else
+ return false;
+}
+
QVariant
CommunitiesModel::data(const QModelIndex &index, int role) const
{
@@ -37,10 +56,16 @@ CommunitiesModel::data(const QModelIndex &index, int role) const
return tr("All rooms");
case CommunitiesModel::Roles::Tooltip:
return tr("Shows all rooms without filtering.");
- case CommunitiesModel::Roles::ChildrenHidden:
+ case CommunitiesModel::Roles::Collapsed:
+ return false;
+ case CommunitiesModel::Roles::Collapsible:
return false;
case CommunitiesModel::Roles::Hidden:
return false;
+ case CommunitiesModel::Roles::Parent:
+ return "";
+ case CommunitiesModel::Roles::Depth:
+ return 0;
case CommunitiesModel::Roles::Id:
return "";
}
@@ -52,25 +77,43 @@ CommunitiesModel::data(const QModelIndex &index, int role) const
return tr("Direct Chats");
case CommunitiesModel::Roles::Tooltip:
return tr("Show direct chats.");
- case CommunitiesModel::Roles::ChildrenHidden:
+ case CommunitiesModel::Roles::Collapsed:
+ return false;
+ case CommunitiesModel::Roles::Collapsible:
return false;
case CommunitiesModel::Roles::Hidden:
return hiddentTagIds_.contains("dm");
+ case CommunitiesModel::Roles::Parent:
+ return "";
+ case CommunitiesModel::Roles::Depth:
+ return 0;
case CommunitiesModel::Roles::Id:
return "dm";
}
} else if (index.row() - 2 < spaceOrder_.size()) {
- auto id = spaceOrder_.at(index.row() - 2);
+ auto id = spaceOrder_.tree.at(index.row() - 2).name;
switch (role) {
case CommunitiesModel::Roles::AvatarUrl:
return QString::fromStdString(spaces_.at(id).avatar_url);
case CommunitiesModel::Roles::DisplayName:
case CommunitiesModel::Roles::Tooltip:
return QString::fromStdString(spaces_.at(id).name);
- case CommunitiesModel::Roles::ChildrenHidden:
- return true;
+ case CommunitiesModel::Roles::Collapsed:
+ return spaceOrder_.tree.at(index.row() - 2).collapsed;
+ case CommunitiesModel::Roles::Collapsible: {
+ auto idx = index.row() - 2;
+ return idx != spaceOrder_.lastChild(idx);
+ }
case CommunitiesModel::Roles::Hidden:
return hiddentTagIds_.contains("space:" + id);
+ case CommunitiesModel::Roles::Parent: {
+ if (auto p = spaceOrder_.parent(index.row() - 2); p >= 0)
+ return spaceOrder_.tree[p].name;
+
+ return "";
+ }
+ case CommunitiesModel::Roles::Depth:
+ return spaceOrder_.tree.at(index.row() - 2).depth;
case CommunitiesModel::Roles::Id:
return "space:" + id;
}
@@ -116,8 +159,14 @@ CommunitiesModel::data(const QModelIndex &index, int role) const
switch (role) {
case CommunitiesModel::Roles::Hidden:
return hiddentTagIds_.contains("tag:" + tag);
- case CommunitiesModel::Roles::ChildrenHidden:
+ case CommunitiesModel::Roles::Collapsed:
return true;
+ case CommunitiesModel::Roles::Collapsible:
+ return false;
+ case CommunitiesModel::Roles::Parent:
+ return "";
+ case CommunitiesModel::Roles::Depth:
+ return 0;
case CommunitiesModel::Roles::Id:
return "tag:" + tag;
}
@@ -125,21 +174,67 @@ CommunitiesModel::data(const QModelIndex &index, int role) const
return QVariant();
}
+namespace {
+struct temptree
+{
+ std::map children;
+
+ void insert(const std::vector &parents, const std::string &child)
+ {
+ temptree *t = this;
+ for (const auto &e : parents)
+ t = &t->children[e];
+ t->children[child];
+ }
+
+ void flatten(CommunitiesModel::FlatTree &to, int i = 0) const
+ {
+ for (const auto &[child, subtree] : children) {
+ to.tree.push_back({QString::fromStdString(child), i, false});
+ subtree.flatten(to, i + 1);
+ }
+ }
+};
+
+void
+addChildren(temptree &t,
+ std::vector path,
+ std::string child,
+ const std::map> &children)
+{
+ if (std::find(path.begin(), path.end(), child) != path.end())
+ return;
+
+ path.push_back(child);
+
+ if (children.count(child)) {
+ for (const auto &c : children.at(child)) {
+ t.insert(path, c);
+ addChildren(t, path, c, children);
+ }
+ }
+}
+}
+
void
CommunitiesModel::initializeSidebar()
{
beginResetModel();
tags_.clear();
- spaceOrder_.clear();
+ spaceOrder_.tree.clear();
spaces_.clear();
std::set ts;
- std::vector tempSpaces;
+
+ std::set isSpace;
+ std::map> spaceChilds;
+ std::map> spaceParents;
+
auto infos = cache::roomInfo();
for (auto it = infos.begin(); it != infos.end(); it++) {
if (it.value().is_space) {
- spaceOrder_.push_back(it.key());
spaces_[it.key()] = it.value();
+ isSpace.insert(it.key().toStdString());
} else {
for (const auto &t : it.value().tags) {
if (t.find("u.") == 0 || t.find("m." == 0)) {
@@ -149,6 +244,34 @@ CommunitiesModel::initializeSidebar()
}
}
+ // NOTE(Nico): We build a forrest from the Directed Cyclic(!) Graph of spaces. To do that we
+ // start with orphan spaces at the top. This leaves out some space circles, but there is no good
+ // way to break that cycle imo anyway. Then we carefully walk a tree down from each root in our
+ // forrest, carefully checking not to run in a circle and get lost forever.
+ // TODO(Nico): Optimize this. We can do this with a lot fewer allocations and checks.
+ for (const auto &space : isSpace) {
+ spaceParents[space];
+ for (const auto &p : cache::client()->getParentRoomIds(space)) {
+ spaceParents[space].insert(p);
+ spaceChilds[p].insert(space);
+ }
+ }
+
+ temptree spacetree;
+ std::vector path;
+ for (const auto &space : isSpace) {
+ if (!spaceParents[space].empty())
+ continue;
+
+ spacetree.children[space] = {};
+ }
+ for (const auto &space : spacetree.children) {
+ addChildren(spacetree, path, space.first, spaceChilds);
+ }
+
+ // NOTE(Nico): This flattens the tree into a list, preserving the depth at each element.
+ spacetree.flatten(spaceOrder_);
+
for (const auto &t : ts)
tags_.push_back(QString::fromStdString(t));
@@ -199,7 +322,7 @@ CommunitiesModel::sync(const mtx::responses::Sync &sync_)
}
for (const auto &[roomid, room] : sync_.rooms.leave) {
(void)room;
- if (spaceOrder_.contains(QString::fromStdString(roomid)))
+ if (spaces_.count(QString::fromStdString(roomid)))
tagsUpdated = true;
}
for (const auto &e : sync_.account_data.events) {
@@ -228,8 +351,8 @@ CommunitiesModel::setCurrentTagId(QString tagId)
}
} else if (tagId.startsWith("space:")) {
auto tag = tagId.mid(6);
- for (const auto &t : spaceOrder_) {
- if (t == tag) {
+ for (const auto &t : spaceOrder_.tree) {
+ if (t.name == tag) {
this->currentTagId_ = tagId;
emit currentTagIdChanged(currentTagId_);
return;
@@ -271,3 +394,88 @@ CommunitiesModel::toggleTagId(QString tagId)
emit hiddenTagsChanged();
}
+
+FilteredCommunitiesModel::FilteredCommunitiesModel(CommunitiesModel *model, QObject *parent)
+ : QSortFilterProxyModel(parent)
+{
+ setSourceModel(model);
+ setDynamicSortFilter(true);
+ sort(0);
+}
+
+namespace {
+enum Categories
+{
+ World,
+ Direct,
+ Favourites,
+ Server,
+ LowPrio,
+ Space,
+ UserTag,
+};
+
+Categories
+tagIdToCat(QString tagId)
+{
+ if (tagId.isEmpty())
+ return World;
+ else if (tagId == "dm")
+ return Direct;
+ else if (tagId == "tag:m.favourite")
+ return Favourites;
+ else if (tagId == "tag:m.server_notice")
+ return Server;
+ else if (tagId == "tag:m.lowpriority")
+ return LowPrio;
+ else if (tagId.startsWith("space:"))
+ return Space;
+ else
+ return UserTag;
+}
+}
+
+bool
+FilteredCommunitiesModel::lessThan(const QModelIndex &left, const QModelIndex &right) const
+{
+ nhlog::ui()->debug("lessThan");
+ QModelIndex const left_idx = sourceModel()->index(left.row(), 0, QModelIndex());
+ QModelIndex const right_idx = sourceModel()->index(right.row(), 0, QModelIndex());
+
+ Categories leftCat = tagIdToCat(sourceModel()->data(left_idx, CommunitiesModel::Id).toString());
+ Categories rightCat =
+ tagIdToCat(sourceModel()->data(right_idx, CommunitiesModel::Id).toString());
+
+ if (leftCat != rightCat)
+ return leftCat < rightCat;
+
+ if (leftCat == Space) {
+ return left.row() < right.row();
+ }
+
+ QString leftName = sourceModel()->data(left_idx, CommunitiesModel::DisplayName).toString();
+ QString rightName = sourceModel()->data(right_idx, CommunitiesModel::DisplayName).toString();
+
+ return leftName.compare(rightName, Qt::CaseInsensitive) < 0;
+}
+bool
+FilteredCommunitiesModel::filterAcceptsRow(int sourceRow, const QModelIndex &) const
+{
+ CommunitiesModel *m = qobject_cast(this->sourceModel());
+ if (!m)
+ return true;
+
+ if (sourceRow < 2 || sourceRow - 2 >= m->spaceOrder_.size())
+ return true;
+
+ auto idx = sourceRow - 2;
+
+ while (idx >= 0 && m->spaceOrder_.tree[idx].depth > 0) {
+ idx = m->spaceOrder_.parent(idx);
+
+ if (idx >= 0 && m->spaceOrder_.tree.at(idx).collapsed)
+ return false;
+ }
+
+ return true;
+}
diff --git a/src/timeline/CommunitiesModel.h b/src/timeline/CommunitiesModel.h
index 114e3f94..79f8c33a 100644
--- a/src/timeline/CommunitiesModel.h
+++ b/src/timeline/CommunitiesModel.h
@@ -6,6 +6,7 @@
#include
#include
+#include
#include
#include
@@ -13,6 +14,18 @@
#include "CacheStructs.h"
+class CommunitiesModel;
+
+class FilteredCommunitiesModel : public QSortFilterProxyModel
+{
+ Q_OBJECT
+
+public:
+ FilteredCommunitiesModel(CommunitiesModel *model, QObject *parent = nullptr);
+ bool lessThan(const QModelIndex &left, const QModelIndex &right) const override;
+ bool filterAcceptsRow(int sourceRow, const QModelIndex &) const override;
+};
+
class CommunitiesModel : public QAbstractListModel
{
Q_OBJECT
@@ -27,11 +40,59 @@ public:
AvatarUrl = Qt::UserRole,
DisplayName,
Tooltip,
- ChildrenHidden,
+ Collapsed,
+ Collapsible,
Hidden,
+ Parent,
+ Depth,
Id,
};
+ struct FlatTree
+ {
+ struct Elem
+ {
+ QString name;
+ int depth = 0;
+ bool collapsed = false;
+ };
+
+ std::vector tree;
+
+ int size() const { return static_cast(tree.size()); }
+ int indexOf(const QString &s) const
+ {
+ for (int i = 0; i < size(); i++)
+ if (tree[i].name == s)
+ return i;
+ return -1;
+ }
+ int lastChild(int index) const
+ {
+ if (index >= size() || index < 0)
+ return index;
+ const auto depth = tree[index].depth;
+ int i = index + 1;
+ for (; i < size(); i++)
+ if (tree[i].depth == depth)
+ break;
+ return i - 1;
+ }
+ int parent(int index) const
+ {
+ if (index >= size() || index < 0)
+ return -1;
+ const auto depth = tree[index].depth;
+ if (depth == 0)
+ return -1;
+ int i = index - 1;
+ for (; i >= 0; i--)
+ if (tree[i].depth < depth)
+ break;
+ return i;
+ }
+ };
+
CommunitiesModel(QObject *parent = nullptr);
QHash roleNames() const override;
int rowCount(const QModelIndex &parent = QModelIndex()) const override
@@ -40,6 +101,7 @@ public:
return 2 + tags_.size() + spaceOrder_.size();
}
QVariant data(const QModelIndex &index, int role) const override;
+ bool setData(const QModelIndex &index, const QVariant &value, int role = Qt::EditRole) override;
public slots:
void initializeSidebar();
@@ -63,6 +125,7 @@ public slots:
return tagsWD;
}
void toggleTagId(QString tagId);
+ FilteredCommunitiesModel *filtered() { return new FilteredCommunitiesModel(this, this); }
signals:
void currentTagIdChanged(QString tagId);
@@ -73,6 +136,8 @@ private:
QStringList tags_;
QString currentTagId_;
QStringList hiddentTagIds_;
- QStringList spaceOrder_;
+ FlatTree spaceOrder_;
std::map spaces_;
+
+ friend class FilteredCommunitiesModel;
};
diff --git a/src/timeline/TimelineViewManager.cpp b/src/timeline/TimelineViewManager.cpp
index 07fb0417..3bc246b9 100644
--- a/src/timeline/TimelineViewManager.cpp
+++ b/src/timeline/TimelineViewManager.cpp
@@ -260,6 +260,13 @@ TimelineViewManager::TimelineViewManager(CallManager *callManager, ChatPage *par
qRegisterMetaType();
qRegisterMetaType>();
+ qmlRegisterUncreatableType(
+ "im.nheko",
+ 1,
+ 0,
+ "FilteredCommunitiesModel",
+ "Use Communities.filtered() to create a FilteredCommunitiesModel");
+
qmlRegisterType("im.nheko.EmojiModel", 1, 0, "EmojiModel");
qmlRegisterUncreatableType(
"im.nheko.EmojiModel", 1, 0, "Emoji", "Used by emoji models");