diff --git a/resources/icons/ui/lowprio.png b/resources/icons/ui/lowprio.png new file mode 100644 index 00000000..b815d8bb Binary files /dev/null and b/resources/icons/ui/lowprio.png differ diff --git a/resources/icons/ui/lowprio@2x.png b/resources/icons/ui/lowprio@2x.png new file mode 100644 index 00000000..4581946e Binary files /dev/null and b/resources/icons/ui/lowprio@2x.png differ diff --git a/resources/icons/ui/star.png b/resources/icons/ui/star.png new file mode 100644 index 00000000..f2c73243 Binary files /dev/null and b/resources/icons/ui/star.png differ diff --git a/resources/icons/ui/star@2x.png b/resources/icons/ui/star@2x.png new file mode 100644 index 00000000..0cde94d8 Binary files /dev/null and b/resources/icons/ui/star@2x.png differ diff --git a/resources/icons/ui/tag.png b/resources/icons/ui/tag.png new file mode 100644 index 00000000..61ae6b83 Binary files /dev/null and b/resources/icons/ui/tag.png differ diff --git a/resources/icons/ui/tag@2x.png b/resources/icons/ui/tag@2x.png new file mode 100644 index 00000000..5a6769b0 Binary files /dev/null and b/resources/icons/ui/tag@2x.png differ diff --git a/resources/res.qrc b/resources/res.qrc index d024a5d5..cef55773 100644 --- a/resources/res.qrc +++ b/resources/res.qrc @@ -53,6 +53,13 @@ icons/ui/world.png icons/ui/world@2x.png + icons/ui/tag.png + icons/ui/tag@2x.png + icons/ui/star.png + icons/ui/star@2x.png + icons/ui/lowprio.png + icons/ui/lowprio@2x.png + icons/ui/edit.png icons/ui/edit@2x.png diff --git a/src/Cache.cpp b/src/Cache.cpp index 372dd44a..a9094e2d 100644 --- a/src/Cache.cpp +++ b/src/Cache.cpp @@ -936,6 +936,8 @@ Cache::calculateRoomReadStatus(const std::string &room_id) void Cache::saveState(const mtx::responses::Sync &res) { + using namespace mtx::events; + auto txn = lmdb::txn::begin(env_); setNextBatchToken(txn, res.next_batch); @@ -957,6 +959,35 @@ Cache::saveState(const mtx::responses::Sync &res) getRoomAvatarUrl(txn, statesdb, membersdb, QString::fromStdString(room.first)) .toStdString(); + // Process the account_data associated with this room + bool has_new_tags = false; + for (const auto &evt : room.second.account_data.events) { + // for now only fetch tag events + if (evt.type() == typeid(Event)) { + auto tags_evt = boost::get>(evt); + has_new_tags = true; + for (const auto &tag : tags_evt.content.tags) { + updatedInfo.tags.push_back(tag.first); + } + } + } + if (!has_new_tags) { + // retrieve the old tags, they haven't changed + lmdb::val data; + if (lmdb::dbi_get(txn, roomsDb_, lmdb::val(room.first), data)) { + try { + RoomInfo tmp = + json::parse(std::string(data.data(), data.size())); + updatedInfo.tags = tmp.tags; + } catch (const json::exception &e) { + nhlog::db()->warn( + "failed to parse room info: room_id ({}), {}", + room.first, + std::string(data.data(), data.size())); + } + } + } + lmdb::dbi_put( txn, roomsDb_, lmdb::val(room.first), lmdb::val(json(updatedInfo).dump())); @@ -1078,6 +1109,27 @@ Cache::roomsWithStateUpdates(const mtx::responses::Sync &res) return rooms; } +std::vector +Cache::roomsWithTagUpdates(const mtx::responses::Sync &res) +{ + using namespace mtx::events; + + std::vector rooms; + for (const auto &room : res.rooms.join) { + bool hasUpdates = false; + for (const auto &evt : room.second.account_data.events) { + if (evt.type() == typeid(Event)) { + hasUpdates = true; + } + } + + if (hasUpdates) + rooms.emplace_back(room.first); + } + + return rooms; +} + RoomInfo Cache::singleRoomInfo(const std::string &room_id) { diff --git a/src/Cache.h b/src/Cache.h index 5bdfb113..b730d6fc 100644 --- a/src/Cache.h +++ b/src/Cache.h @@ -115,6 +115,8 @@ struct RoomInfo bool guest_access = false; //! Metadata describing the last message in the timeline. DescInfo msgInfo; + //! The list of tags associated with this room + std::vector tags; }; inline void @@ -129,6 +131,9 @@ to_json(json &j, const RoomInfo &info) if (info.member_count != 0) j["member_count"] = info.member_count; + + if (info.tags.size() != 0) + j["tags"] = info.tags; } inline void @@ -143,6 +148,9 @@ from_json(const json &j, RoomInfo &info) if (j.count("member_count")) info.member_count = j.at("member_count"); + + if (j.count("tags")) + info.tags = j.at("tags").get>(); } //! Basic information per member; @@ -384,11 +392,16 @@ public: RoomInfo singleRoomInfo(const std::string &room_id); std::vector roomsWithStateUpdates(const mtx::responses::Sync &res); + std::vector roomsWithTagUpdates(const mtx::responses::Sync &res); std::map getRoomInfo(const std::vector &rooms); std::map roomUpdates(const mtx::responses::Sync &sync) { return getRoomInfo(roomsWithStateUpdates(sync)); } + std::map roomTagUpdates(const mtx::responses::Sync &sync) + { + return getRoomInfo(roomsWithTagUpdates(sync)); + } //! Calculates which the read status of a room. //! Whether all the events in the timeline have been read. diff --git a/src/ChatPage.cpp b/src/ChatPage.cpp index 3a534df1..6a7e7d81 100644 --- a/src/ChatPage.cpp +++ b/src/ChatPage.cpp @@ -569,6 +569,7 @@ ChatPage::ChatPage(QSharedPointer userSettings, QWidget *parent) }); }); connect(this, &ChatPage::syncRoomlist, room_list_, &RoomList::sync); + connect(this, &ChatPage::syncTags, communitiesList_, &CommunitiesList::syncTags); connect( this, &ChatPage::syncTopBar, this, [this](const std::map &updates) { if (updates.find(currentRoom()) != updates.end()) @@ -797,6 +798,7 @@ ChatPage::loadStateFromCache() emit initializeEmptyViews(cache::client()->roomMessages()); emit initializeRoomList(cache::client()->roomInfo()); + emit syncTags(cache::client()->roomInfo().toStdMap()); cache::client()->calculateRoomReadStatus(); @@ -1079,6 +1081,8 @@ ChatPage::trySync() emit syncTopBar(updates); emit syncRoomlist(updates); + emit syncTags(cache::client()->roomTagUpdates(res)); + cache::client()->deleteOldData(); } catch (const lmdb::map_full_error &e) { nhlog::db()->error("lmdb is full: {}", e.what()); @@ -1213,6 +1217,7 @@ ChatPage::initialSyncHandler(const mtx::responses::Sync &res, mtx::http::Request emit initializeRoomList(cache::client()->roomInfo()); cache::client()->calculateRoomReadStatus(); + emit syncTags(cache::client()->roomInfo().toStdMap()); } catch (const lmdb::error &e) { nhlog::db()->error("failed to save state after initial sync: {}", e.what()); startInitialSync(); diff --git a/src/ChatPage.h b/src/ChatPage.h index dc30e497..2c728c17 100644 --- a/src/ChatPage.h +++ b/src/ChatPage.h @@ -136,6 +136,7 @@ signals: void initializeEmptyViews(const std::map &msgs); void syncUI(const mtx::responses::Rooms &rooms); void syncRoomlist(const std::map &updates); + void syncTags(const std::map &updates); void syncTopBar(const std::map &updates); void dropToLoginPageCb(const QString &msg); diff --git a/src/CommunitiesList.cpp b/src/CommunitiesList.cpp index 7054db9d..fc762376 100644 --- a/src/CommunitiesList.cpp +++ b/src/CommunitiesList.cpp @@ -47,7 +47,15 @@ CommunitiesList::CommunitiesList(QWidget *parent) void CommunitiesList::setCommunities(const mtx::responses::JoinedGroups &response) { - communities_.clear(); + // remove all non-tag communities + auto it = communities_.begin(); + while (it != communities_.end()) { + if (it->second->is_tag()) { + ++it; + } else { + it = communities_.erase(it); + } + } addGlobalItem(); @@ -56,6 +64,60 @@ CommunitiesList::setCommunities(const mtx::responses::JoinedGroups &response) communities_["world"]->setPressedState(true); emit communityChanged("world"); + sortEntries(); +} + +void +CommunitiesList::syncTags(const std::map &info) +{ + for (const auto &room : info) + setTagsForRoom(room.first, room.second.tags); + sortEntries(); +} + +void +CommunitiesList::setTagsForRoom(const QString &room_id, const std::vector &tags) +{ + // create missing tag if any + for (const auto &tag : tags) { + // filter out tags we should ignore according to the spec + // https://matrix.org/docs/spec/client_server/r0.4.0.html#id154 + // nheko currently does not make use of internal tags + // so we ignore any tag containig a `.` (which would indicate a tag + // in the form `tld.domain.*`) except for `m.*` and `u.*`. + if (tag.find(".") != ::std::string::npos && tag.compare(0, 2, "m.") && + tag.compare(0, 2, "u.")) + continue; + QString name = QString("tag:") + QString::fromStdString(tag); + if (!communityExists(name)) { + addCommunity(std::string("tag:") + tag); + } + } + // update membership of the room for all tags + auto it = communities_.begin(); + while (it != communities_.end()) { + // Skip if the community is not a tag + if (!it->second->is_tag()) { + ++it; + continue; + } + // insert or remove the room from the tag as appropriate + std::string current_tag = + it->first.right(it->first.size() - strlen("tag:")).toStdString(); + if (std::find(tags.begin(), tags.end(), current_tag) != tags.end()) { + // the room has this tag + it->second->addRoom(room_id); + } else { + // the room does not have this tag + it->second->delRoom(room_id); + } + // Check if the tag is now empty, if yes delete it + if (it->second->rooms().empty()) { + it = communities_.erase(it); + } else { + ++it; + } + } } void @@ -193,3 +255,47 @@ CommunitiesList::roomList(const QString &id) const return {}; } + +void +CommunitiesList::sortEntries() +{ + std::vector header; + std::vector communities; + std::vector tags; + std::vector footer; + // remove all the contents and sort them in the 4 vectors + for (auto &entry : communities_) { + CommunitiesListItem *item = entry.second.data(); + contentsLayout_->removeWidget(item); + // world is handled separately + if (entry.first == "world") + continue; + // sort the rest + if (item->is_tag()) + if (entry.first == "tag:m.favourite") + header.push_back(item); + else if (entry.first == "tag:m.lowpriority") + footer.push_back(item); + else + tags.push_back(item); + else + communities.push_back(item); + } + + // now there remains only the stretch in the layout, remove it + QLayoutItem *stretch = contentsLayout_->itemAt(0); + contentsLayout_->removeItem(stretch); + + contentsLayout_->addWidget(communities_["world"].data()); + + auto insert_widgets = [this](auto &vec) { + for (auto item : vec) + contentsLayout_->addWidget(item); + }; + insert_widgets(header); + insert_widgets(communities); + insert_widgets(tags); + insert_widgets(footer); + + contentsLayout_->addItem(stretch); +} diff --git a/src/CommunitiesList.h b/src/CommunitiesList.h index d4db54cc..b18df654 100644 --- a/src/CommunitiesList.h +++ b/src/CommunitiesList.h @@ -4,6 +4,7 @@ #include #include +#include "Cache.h" #include "CommunitiesListItem.h" #include "ui/Theme.h" @@ -20,6 +21,9 @@ public: void removeCommunity(const QString &id) { communities_.erase(id); }; std::map roomList(const QString &id) const; + void syncTags(const std::map &info); + void setTagsForRoom(const QString &id, const std::vector &tags); + signals: void communityChanged(const QString &id); void avatarRetrieved(const QString &id, const QPixmap &img); @@ -34,6 +38,7 @@ public slots: private: void fetchCommunityAvatar(const QString &id, const QString &avatarUrl); void addGlobalItem() { addCommunity("world"); } + void sortEntries(); //! Check whether or not a community id is currently managed. bool communityExists(const QString &id) const diff --git a/src/CommunitiesListItem.cpp b/src/CommunitiesListItem.cpp index f2777e66..0fad6624 100644 --- a/src/CommunitiesListItem.cpp +++ b/src/CommunitiesListItem.cpp @@ -19,6 +19,21 @@ CommunitiesListItem::CommunitiesListItem(QString group_id, QWidget *parent) if (groupId_ == "world") avatar_ = QPixmap(":/icons/icons/ui/world.png"); + else if (groupId_ == "tag:m.favourite") + avatar_ = QPixmap(":/icons/icons/ui/star.png"); + else if (groupId_ == "tag:m.lowpriority") + avatar_ = QPixmap(":/icons/icons/ui/lowprio.png"); + else if (groupId_.startsWith("tag:")) + avatar_ = QPixmap(":/icons/icons/ui/tag.png"); + + updateTooltip(); +} + +void +CommunitiesListItem::setName(QString name) +{ + name_ = name; + updateTooltip(); } void @@ -98,7 +113,8 @@ CommunitiesListItem::resolveName() const { if (!name_.isEmpty()) return name_; - + if (groupId_.startsWith("tag:")) + return groupId_.right(groupId_.size() - strlen("tag:")); if (!groupId_.startsWith("+")) return QString("Group"); // Group with no name or id. @@ -106,3 +122,24 @@ CommunitiesListItem::resolveName() const auto firstPart = groupId_.split(':').at(0); return firstPart.right(firstPart.size() - 1); } + +void +CommunitiesListItem::updateTooltip() +{ + if (groupId_ == "world") + setToolTip(tr("All rooms")); + else if (is_tag()) { + QString tag = groupId_.right(groupId_.size() - strlen("tag:")); + if (tag == "m.favourite") + setToolTip(tr("Favourite rooms")); + else if (tag == "m.lowpriority") + setToolTip(tr("Low priority rooms")); + else if (tag.startsWith("u.")) + setToolTip(tag.right(tag.size() - 2) + tr(" (tag)")); + else + setToolTip(tag + tr(" (tag)")); + } else { + QString name = resolveName(); + setToolTip(name + tr(" (community)")); + } +} \ No newline at end of file diff --git a/src/CommunitiesListItem.h b/src/CommunitiesListItem.h index bfd54661..d4d7e9c6 100644 --- a/src/CommunitiesListItem.h +++ b/src/CommunitiesListItem.h @@ -28,13 +28,17 @@ class CommunitiesListItem : public QWidget public: CommunitiesListItem(QString group_id, QWidget *parent = nullptr); - void setName(QString name) { name_ = name; } + void setName(QString name); bool isPressed() const { return isPressed_; } void setAvatar(const QImage &img); void setRooms(std::map room_ids) { room_ids_ = std::move(room_ids); } + void addRoom(const QString &id) { room_ids_[id] = true; } + void delRoom(const QString &id) { room_ids_.erase(id); } std::map rooms() const { return room_ids_; } + bool is_tag() const { return groupId_.startsWith("tag:"); } + QColor highlightedBackgroundColor() const { return highlightedBackgroundColor_; } QColor hoverBackgroundColor() const { return hoverBackgroundColor_; } QColor backgroundColor() const { return backgroundColor_; } @@ -68,6 +72,7 @@ private: const int IconSize = 36; QString resolveName() const; + void updateTooltip(); std::map room_ids_;