diff --git a/retroshare-gui/CMakeLists.txt b/retroshare-gui/CMakeLists.txt index db558e270..c32a23c2f 100644 --- a/retroshare-gui/CMakeLists.txt +++ b/retroshare-gui/CMakeLists.txt @@ -62,6 +62,7 @@ option( RS_GXSCHANNELS "Enable GXS channels in GUI" ON ) option( RS_GXSFORUMS "Enable GXS forums in GUI" ON ) option( RS_GXSPOSTED "Enable GXS posted in GUI" ON ) option( RS_GXSCIRCLES "Enable GXS circles in GUI" ON ) +option( RS_GXSPEOPLE "Enable GXS people page in GUI" OFF ) option( RS_GUI_CMARK "Enable CommonMark support in GUI" OFF ) set(RS_GXSIDENTITIES ON CACHE BOOL "Enable GXS identities in GUI" FORCE) set(RS_IDLE ON CACHE BOOL "Enable Idle support" FORCE) @@ -664,6 +665,10 @@ if(RS_GXSCIRCLES) target_compile_definitions(${PROJECT_NAME} PRIVATE RS_USE_CIRCLES ) endif(RS_GXSCIRCLES) +if(RS_GXSPEOPLE) + target_compile_definitions(${PROJECT_NAME} PRIVATE RS_USE_NEW_PEOPLE ) +endif(RS_GXSPEOPLE) + # NOTE: the GUI does NOT use botan, json-c, z or bz2 directly (it uses rapidjson # for JSON). They are transitive dependencies of librnp (PGP), pulled in here only # because the statically-linked librnp does not propagate them. Ideally librnp diff --git a/retroshare-gui/src/CMakeLists.txt b/retroshare-gui/src/CMakeLists.txt index d2025ae9c..2b6007add 100644 --- a/retroshare-gui/src/CMakeLists.txt +++ b/retroshare-gui/src/CMakeLists.txt @@ -1078,30 +1078,44 @@ if(RS_GXSCIRCLES) APPEND RS_IMPLEMENTATION_HEADERS src/gui/Circles/CirclesDialog.h src/gui/Circles/CreateCircleDialog.h - src/gui/People/PeopleDialog.h - src/gui/People/CircleWidget.h - src/gui/People/IdentityWidget.h ) list( APPEND RS_GUI_FORMS src/gui/Circles/CirclesDialog.ui src/gui/Circles/CreateCircleDialog.ui - src/gui/People/PeopleDialog.ui - src/gui/People/CircleWidget.ui - src/gui/People/IdentityWidget.ui ) list( APPEND RS_GUI_SOURCES src/gui/Circles/CirclesDialog.cpp src/gui/Circles/CreateCircleDialog.cpp - src/gui/People/PeopleDialog.cpp - src/gui/People/CircleWidget.cpp - src/gui/People/IdentityWidget.cpp ) endif(RS_GXSCIRCLES) +if(RS_GXSPEOPLE) + list( + APPEND RS_IMPLEMENTATION_HEADERS + src/gui/People/CircleWidget.h + src/gui/People/IdentityWidget.h + src/gui/People/PeopleDialog.h + ) + + list( + APPEND RS_GUI_FORMS + src/gui/People/CircleWidget.ui + src/gui/People/IdentityWidget.ui + src/gui/People/PeopleDialog.ui + ) + + list( + APPEND RS_GUI_SOURCES + src/gui/People/CircleWidget.cpp + src/gui/People/IdentityWidget.cpp + src/gui/People/PeopleDialog.cpp + ) +endif(RS_GXSPEOPLE) + if(RS_GXSGUI) list( APPEND RS_IMPLEMENTATION_HEADERS diff --git a/retroshare-gui/src/gui/Identity/IdentityListModel.cpp b/retroshare-gui/src/gui/Identity/IdentityListModel.cpp index 2327ed3d3..58fadd4fd 100644 --- a/retroshare-gui/src/gui/Identity/IdentityListModel.cpp +++ b/retroshare-gui/src/gui/Identity/IdentityListModel.cpp @@ -300,9 +300,7 @@ RsIdentityListModel::EntryIndex RsIdentityListModel::EntryIndex::parent() const break; case ENTRY_TYPE_TOP_LEVEL: std::cerr << "ERROR: calling parent() on entryindex with no parent!" << std::endl; - - default: - //Can be when request root index. + i.type = ENTRY_TYPE_TOP_LEVEL; break; } diff --git a/retroshare-gui/src/gui/Identity/UsageStatistics.cpp b/retroshare-gui/src/gui/Identity/UsageStatistics.cpp new file mode 100644 index 000000000..712f7fd89 --- /dev/null +++ b/retroshare-gui/src/gui/Identity/UsageStatistics.cpp @@ -0,0 +1,385 @@ +/******************************************************************************* + * retroshare-gui/src/gui/Identity/UsageStatistics.cpp * + * * + * Copyright (C) 2026 by Retroshare Team * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU Affero General Public License as * + * published by the Free Software Foundation, either version 3 of the * + * License, or (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU Affero General Public License for more details. * + * * + * You should have received a copy of the GNU Affero General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +#include +#include + +#include "UsageStatistics.h" +#include "ui_UsageStatistics.h" + +#include "gui/RetroShareLink.h" +#include "gui/gxs/GxsIdDetails.h" +#include "util/DateTime.h" +#include "util/rstime.h" + +#include "retroshare/rsgxsflags.h" +#include "retroshare/rschats.h" +#include "retroshare/rspeers.h" +#include "retroshare/rsservicecontrol.h" +#include "retroshare/rsgxschannels.h" +#include "retroshare/rsgxsforums.h" +#include "retroshare/rsposted.h" +#ifdef RS_USE_WIRE +#include "retroshare/rswire.h" +#endif + +#include +#include +#include + +/****** + * #define ID_DEBUG 1 + *****/ + +/** Constructor */ +UsageStatistics::UsageStatistics(QWidget *parent) + : QWidget(parent), ui(new Ui::UsageStatistics) +{ + ui->setupUi(this); + +} + +/** Destructor */ +UsageStatistics::~UsageStatistics() +{ + delete ui; +} + +static QString getHumanReadableDuration(uint32_t seconds) +{ + if(seconds < 60) + return QString(QObject::tr("%1 seconds ago")).arg(seconds) ; + else if(seconds < 120) + return QString(QObject::tr("%1 minute ago")).arg(seconds/60) ; + else if(seconds < 3600) + return QString(QObject::tr("%1 minutes ago")).arg(seconds/60) ; + else if(seconds < 7200) + return QString(QObject::tr("%1 hour ago")).arg(seconds/3600) ; + else if(seconds < 24*3600) + return QString(QObject::tr("%1 hours ago")).arg(seconds/3600) ; + else if(seconds < 2*24*3600) + return QString(QObject::tr("%1 day ago")).arg(seconds/86400) ; + else + return QString(QObject::tr("%1 days ago")).arg(seconds/86400) ; +} + +QString UsageStatistics::createUsageString(const RsIdentityUsage& u) const +{ + QString service_name; + RetroShareLink::enumType service_type = RetroShareLink::TYPE_UNKNOWN; + + switch(u.mServiceId) + { + case RsServiceType::CHANNELS: service_name = tr("Channels") ;service_type = RetroShareLink::TYPE_CHANNEL ; break ; + case RsServiceType::FORUMS: service_name = tr("Forums") ; service_type = RetroShareLink::TYPE_FORUM ; break ; + case RsServiceType::POSTED: service_name = tr("Boards") ; service_type = RetroShareLink::TYPE_POSTED ; break ; + case RsServiceType::CHAT: service_name = tr("Chat") ; service_type = RetroShareLink::TYPE_CHAT_ROOM ; break ; + + case RsServiceType::GXS_TRANS: return tr("GxsMail author "); + + case RsServiceType::GXSCIRCLE: service_name = tr("GxsCircles"); service_type = RetroShareLink::TYPE_CIRCLES; break ; +#ifdef RS_USE_WIRE + case RsServiceType::WIRE: service_name = tr("Wire"); service_type = RetroShareLink::TYPE_WIRE; break ; +#endif + default: + service_name = tr("Unknown (service=")+QString::number((int)u.mServiceId,16)+")"; service_type = RetroShareLink::TYPE_UNKNOWN ; + } + + switch(u.mUsageCode) + { + case RsIdentityUsage::UNKNOWN_USAGE: + return tr("[Unknown]") ; + case RsIdentityUsage::GROUP_ADMIN_SIGNATURE_CREATION: // These 2 are normally not normal GXS identities, but nothing prevents it to happen either. + return tr("Admin signature in service %1").arg(service_name); + case RsIdentityUsage::GROUP_ADMIN_SIGNATURE_VALIDATION: + return tr("Admin signature verification in service %1").arg(service_name); + case RsIdentityUsage::GROUP_AUTHOR_SIGNATURE_CREATION: // not typically used, since most services do not require group author signatures + return tr("Creation of author signature in service %1").arg(service_name); + case RsIdentityUsage::MESSAGE_AUTHOR_SIGNATURE_CREATION: // most common use case. Messages are signed by authors in e.g. forums. + { + QString label = getGroupName(service_type, u.mGrpId); + RetroShareLink l = RetroShareLink::createGxsGroupLink(service_type, u.mGrpId, label); + + return tr("Message signature creation in group %1 of service %2").arg(l.toHtml(), service_name); + } + case RsIdentityUsage::GROUP_AUTHOR_KEEP_ALIVE: // Identities are stamped regularly by crawlign the set of messages for all groups. That helps keepign the useful identities in hand. + case RsIdentityUsage::GROUP_AUTHOR_SIGNATURE_VALIDATION: + { + // label is either the Name or the ID string (from helper) + QString label = getGroupName(service_type, u.mGrpId); + RetroShareLink l = RetroShareLink::createGxsGroupLink(service_type, u.mGrpId, label); + + return tr("Group author for group %1 in service %2").arg(l.toHtml(), service_name); + } + case RsIdentityUsage::MESSAGE_AUTHOR_SIGNATURE_VALIDATION: + case RsIdentityUsage::MESSAGE_AUTHOR_KEEP_ALIVE: // Identities are stamped regularly by crawling the set of messages for all groups. That helps keepign the useful identities in hand. + { + RetroShareLink l; + QString title; + bool titleFound = false; + + // Create a set containing only the ID we are looking for + std::set msgIds; + msgIds.insert(u.mMsgId); + + if (service_type == RetroShareLink::TYPE_CHANNEL && rsGxsChannels) { + std::vector posts; + std::vector cmts; // Local variable to avoid rvalue error + std::vector vots; // Local variable to avoid rvalue error + + if (rsGxsChannels->getChannelContent(u.mGrpId, msgIds, posts, cmts, vots)) { + if (!posts.empty()) { + title = QString::fromUtf8(posts[0].mMeta.mMsgName.c_str()); + titleFound = !title.isEmpty(); + } + } + } + else if (service_type == RetroShareLink::TYPE_FORUM && rsGxsForums) { + std::vector msgs; + // getForumContent only needs 3 arguments, so no extra vectors needed + if (rsGxsForums->getForumContent(u.mGrpId, msgIds, msgs)) { + if (!msgs.empty()) { + title = QString::fromUtf8(msgs[0].mMeta.mMsgName.c_str()); + titleFound = !title.isEmpty(); + } + } + } + else if (service_type == RetroShareLink::TYPE_POSTED && rsPosted) { + std::vector posts; + std::vector cmts; + std::vector vots; + + if (rsPosted->getBoardContent(u.mGrpId, msgIds, posts, cmts, vots)) { + if (!posts.empty()) { + title = QString::fromUtf8(posts[0].mMeta.mMsgName.c_str()); + titleFound = !title.isEmpty(); + } + } + } + + // Prepare the label + QString label; + if (titleFound) { + label = title; + } else { + // Use raw Group ID string if it's a group validation case or total failure + label = QString::fromStdString(u.mGrpId.toStdString()); + } + + + // Generate the link + if ((service_type == RetroShareLink::TYPE_CHANNEL || service_type == RetroShareLink::TYPE_POSTED) && !u.mThreadId.isNull()) { + l = RetroShareLink::createGxsMessageLink(service_type, u.mGrpId, u.mThreadId, label); + } + else { + l = RetroShareLink::createGxsMessageLink(service_type, u.mGrpId, u.mMsgId, label); + } + + // Determine the suffix based on the service type + QString suffix; + if (service_type == RetroShareLink::TYPE_CHANNEL || service_type == RetroShareLink::TYPE_POSTED) { + suffix = tr("Vote/comment"); + } else { + suffix = tr("Message"); + } + + return tr("%2 in %3 service %1").arg(l.toHtml(), suffix, service_name); + } + case RsIdentityUsage::CHAT_LOBBY_MSG_VALIDATION: // Chat lobby msgs are signed, so each time one comes, or a chat lobby event comes, a signature verificaiton happens. + { + ChatId id = ChatId(ChatLobbyId(u.mAdditionalId)); + ChatLobbyInfo linfo ; + rsChats->getChatLobbyInfo(ChatLobbyId(u.mAdditionalId),linfo); + RetroShareLink l = RetroShareLink::createChatRoom(id, QString::fromUtf8(linfo.lobby_name.c_str())); + return tr("Message in chat room %1").arg(l.toHtml()) ; + } + case RsIdentityUsage::GLOBAL_ROUTER_SIGNATURE_CHECK: // Global router message validation + { + return tr("Distant message signature validation."); + } + case RsIdentityUsage::GLOBAL_ROUTER_SIGNATURE_CREATION: // Global router message signature + { + return tr("Distant message signature creation."); + } + case RsIdentityUsage::GXS_TUNNEL_DH_SIGNATURE_CHECK: // + { + return tr("Signature validation in distant tunnel system."); + } + case RsIdentityUsage::GXS_TUNNEL_DH_SIGNATURE_CREATION: // + { + return tr("Signature in distant tunnel system."); + } + case RsIdentityUsage::IDENTITY_NEW_FROM_GXS_SYNC: // Group update on that identity data. Can be avatar, name, etc. + { + return tr("Received from GXS sync."); + } + case RsIdentityUsage::IDENTITY_NEW_FROM_DISCOVERY: // Own friend sended his own ids + { + return tr("Friend node identity received through discovery."); + } + case RsIdentityUsage::IDENTITY_GENERIC_SIGNATURE_CHECK: // Any signature verified for that identity + { + return tr("Generic signature validation."); + } + case RsIdentityUsage::IDENTITY_GENERIC_SIGNATURE_CREATION: // Any signature made by that identity + { + return tr("Generic signature creation (e.g. chat room message, global router,...)."); + } + case RsIdentityUsage::IDENTITY_GENERIC_ENCRYPTION: return tr("Generic encryption."); + case RsIdentityUsage::IDENTITY_GENERIC_DECRYPTION: return tr("Generic decryption."); + case RsIdentityUsage::CIRCLE_MEMBERSHIP_CHECK: + { + RetroShareLink l; + RsGxsCircleDetails det; + + // Try to fetch circle details to get the name + if (rsGxsCircles->getCircleDetails(RsGxsCircleId(u.mGrpId), det)) { + + // Prepare the label: + QString label; + if (!det.mCircleName.empty()) { + label = QString::fromUtf8(det.mCircleName.c_str()) ; + } else { + label = QString::fromStdString(u.mGrpId.toStdString()); + } + + // Create the RetroShareLink for the circle + l = RetroShareLink::createCircle(RsGxsCircleId(u.mGrpId), label); + + // Return the formatted string with the clickable link + if (!det.mCircleName.empty()) { + return tr("Membership verification in circle %1.").arg(l.toHtml()); + } else { + return tr("Membership verification in circle (ID=%1).").arg(l.toHtml()); + } + } + break; + } + +#warning TODO! csoler 2017-01-03: Add the different strings and translations here. + default: + return QString("Undone yet"); + } + return QString("Unknown"); +} + +void UsageStatistics::setUsageData(RsGxsIdGroup data) +{ + time_t now = time(NULL); + RsIdentityDetails det; + rsIdentity->getIdDetails(RsGxsId(data.mMeta.mGroupId), det); + + QString usage_txt; + std::map rmap; + for(auto it(det.mUseCases.begin()); it != det.mUseCases.end(); ++it) + rmap.insert(std::make_pair(it->second, it->first)); + + for(auto it(rmap.begin()); it != rmap.end(); ++it) + usage_txt += QString("") + getHumanReadableDuration(now - data.mLastUsageTS) + " \t: " + createUsageString(it->second) + "
"; + + if(usage_txt.isEmpty()) // .isNull() can sometimes be tricky, .isEmpty() is safer here + usage_txt = tr("[No record in current session]"); + + ui->usageStatistics_TB->setText(usage_txt); +} + +QString UsageStatistics::getGroupName(uint32_t service_type, const RsGxsGroupId& groupId) const +{ + std::list groupIds; + groupIds.push_back(groupId); + + if (service_type == RetroShareLink::TYPE_CHANNEL && rsGxsChannels) { + std::vector groups; + if (rsGxsChannels->getChannelsInfo(groupIds, groups) && !groups.empty()) { + QString name = QString::fromUtf8(groups[0].mMeta.mGroupName.c_str()); + if (!name.isEmpty()) return name; + } + } + else if (service_type == RetroShareLink::TYPE_FORUM && rsGxsForums) { + std::vector groups; + if (rsGxsForums->getForumsInfo(groupIds, groups) && !groups.empty()) { + QString name = QString::fromUtf8(groups[0].mMeta.mGroupName.c_str()); + if (!name.isEmpty()) return name; + } + } + else if (service_type == RetroShareLink::TYPE_POSTED && rsPosted) { + std::vector groups; + if (rsPosted->getBoardsInfo(groupIds, groups) && !groups.empty()) { + QString name = QString::fromUtf8(groups[0].mMeta.mGroupName.c_str()); + if (!name.isEmpty()) return name; + } + } + else if (service_type == RetroShareLink::TYPE_CIRCLES && rsGxsCircles) { + RsGxsCircleDetails det; + if (rsGxsCircles->getCircleDetails(RsGxsCircleId(groupId), det)) { + QString name = QString::fromUtf8(det.mCircleName.c_str()); + if (!name.isEmpty()) return name; + } + } +#ifdef RS_USE_WIRE + else if (service_type == RetroShareLink::TYPE_WIRE && rsWire) { + RsWireGroupSPtr group; // Shared pointer for the result + if (rsWire->getWireGroup(groupId, group) && group) { + QString name = QString::fromUtf8(group->mMeta.mGroupName.c_str()); + if (!name.isEmpty()) return name; + } + } +#endif + + // Returns the raw ID string as the label + return QString::fromStdString(groupId.toStdString()); +} + +QString UsageStatistics::getMessageTitle(uint32_t service_type, const RsGxsGroupId& groupId, const RsGxsMessageId& msgId) const +{ + std::set msgIds; + msgIds.insert(msgId); + + if (service_type == RetroShareLink::TYPE_CHANNEL && rsGxsChannels) { + std::vector posts; + std::vector cmts; + std::vector vots; + if (rsGxsChannels->getChannelContent(groupId, msgIds, posts, cmts, vots) && !posts.empty()) { + return QString::fromUtf8(posts[0].mMeta.mMsgName.c_str()); + } + } + else if (service_type == RetroShareLink::TYPE_FORUM && rsGxsForums) { + std::vector msgs; + if (rsGxsForums->getForumContent(groupId, msgIds, msgs) && !msgs.empty()) { + return QString::fromUtf8(msgs[0].mMeta.mMsgName.c_str()); + } + } + else if (service_type == RetroShareLink::TYPE_POSTED && rsPosted) { + std::vector posts; + std::vector cmts; + std::vector vots; + if (rsPosted->getBoardContent(groupId, msgIds, posts, cmts, vots) && !posts.empty()) { + return QString::fromUtf8(posts[0].mMeta.mMsgName.c_str()); + } + } + else if (service_type == RetroShareLink::TYPE_CIRCLES && rsGxsCircles) { + RsGxsCircleDetails det; + if (rsGxsCircles->getCircleDetails(RsGxsCircleId(groupId), det)) { + return QString::fromUtf8(det.mCircleName.c_str()); + } + } + + return QString(); // Return empty if not found +} diff --git a/retroshare-gui/src/gui/Identity/UsageStatistics.h b/retroshare-gui/src/gui/Identity/UsageStatistics.h new file mode 100644 index 000000000..912ae8e5d --- /dev/null +++ b/retroshare-gui/src/gui/Identity/UsageStatistics.h @@ -0,0 +1,58 @@ +/******************************************************************************* + * retroshare-gui/src/gui/Identity/UsageStatistics.h * + * * + * Copyright (C) 2026 by Retroshare Team * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU Affero General Public License as * + * published by the Free Software Foundation, either version 3 of the * + * License, or (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU Affero General Public License for more details. * + * * + * You should have received a copy of the GNU Affero General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +#ifndef USAGESTATISTICS_H +#define USAGESTATISTICS_H + +#include "gui/gxs/RsGxsUpdateBroadcastPage.h" + +#include "retroshare/rsidentity.h" + +namespace Ui { +class UsageStatistics; +} + + +class UsageStatistics : public QWidget +{ + Q_OBJECT + +public: + UsageStatistics(QWidget *parent = 0); + ~UsageStatistics(); + + void setUsageData(RsGxsIdGroup data); + +protected: + + +private slots: + + +private: + QString createUsageString(const RsIdentityUsage& u) const; + QString getGroupName(uint32_t service_type, const RsGxsGroupId& groupId) const; + QString getMessageTitle(uint32_t service_type, const RsGxsGroupId& groupId, const RsGxsMessageId& msgId) const; + + /* UI - Designer */ + Ui::UsageStatistics *ui; +}; + +#endif diff --git a/retroshare-gui/src/gui/Identity/UsageStatistics.ui b/retroshare-gui/src/gui/Identity/UsageStatistics.ui new file mode 100644 index 000000000..af1388f9e --- /dev/null +++ b/retroshare-gui/src/gui/Identity/UsageStatistics.ui @@ -0,0 +1,52 @@ + + + UsageStatistics + + + + 0 + 0 + 400 + 300 + + + + Form + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Usage statistics + + + + + + + + + + + + + RSTextBrowser + QTextBrowser +
gui/common/RSTextBrowser.h
+
+
+ + +
diff --git a/retroshare-gui/src/gui/MainWindow.cpp b/retroshare-gui/src/gui/MainWindow.cpp index a0ccc5dee..01a2eb8d6 100644 --- a/retroshare-gui/src/gui/MainWindow.cpp +++ b/retroshare-gui/src/gui/MainWindow.cpp @@ -80,7 +80,7 @@ #include "gui/GetStartedDialog.h" #endif -#ifdef RS_USE_CIRCLES +#ifdef RS_USE_NEW_PEOPLE #include "gui/People/PeopleDialog.h" #endif #include "idle/idle.h" @@ -456,10 +456,10 @@ void MainWindow::initStackedPage() addPage(gxsforumDialog = new GxsForumsDialog(ui->stackPages), grp, ¬ify); addPage(postedDialog = new PostedDialog(ui->stackPages), grp, ¬ify); - #ifdef RS_USE_NEW_PEOPLE_DIALOG +#ifdef RS_USE_NEW_PEOPLE PeopleDialog *peopleDialog = NULL; addPage(peopleDialog = new PeopleDialog(ui->stackPages), grp, ¬ify); - #endif +#endif #ifdef RS_USE_WIKI wikiDialog = NULL; addPage(wikiDialog = new WikiDialog(ui->stackPages), grp, ¬ify); diff --git a/retroshare-gui/src/gui/People/CircleWidget.cpp b/retroshare-gui/src/gui/People/CircleWidget.cpp index 03979aee0..c237466ef 100644 --- a/retroshare-gui/src/gui/People/CircleWidget.cpp +++ b/retroshare-gui/src/gui/People/CircleWidget.cpp @@ -52,6 +52,12 @@ CircleWidget::CircleWidget(QString name/*=QString()*/ setAcceptDrops(true); } +void CircleWidget::mousePressEvent(QMouseEvent *event) +{ + emit clicked(); + FlowLayoutItem::mousePressEvent(event); +} + CircleWidget::~CircleWidget() { delete _scene; diff --git a/retroshare-gui/src/gui/People/CircleWidget.h b/retroshare-gui/src/gui/People/CircleWidget.h index 40534897a..7698c772a 100644 --- a/retroshare-gui/src/gui/People/CircleWidget.h +++ b/retroshare-gui/src/gui/People/CircleWidget.h @@ -59,10 +59,14 @@ public: signals: void askForGXSIdentityWidget(RsGxsId gxs_id); void askForPGPIdentityWidget(RsPgpId pgp_id); + void clicked(); // emitted when circle is clicked (for selection state machine) private slots: void updateIdImage(); +protected: + virtual void mousePressEvent(QMouseEvent *event) override; + private: void updateScene(); diff --git a/retroshare-gui/src/gui/People/IdentityWidget.cpp b/retroshare-gui/src/gui/People/IdentityWidget.cpp index 53c9ba8f9..7e1b2a417 100644 --- a/retroshare-gui/src/gui/People/IdentityWidget.cpp +++ b/retroshare-gui/src/gui/People/IdentityWidget.cpp @@ -209,8 +209,8 @@ void IdentityWidget::setIsSelected(bool value) void IdentityWidget::setIsCurrent(bool value) { m_isCurrent=value; - ui->labelKeyId->setVisible(value); - ui->labelGXSId->setVisible(value && (_haveGXSId && _havePGPDetail)); + ui->labelKeyId->setVisible(false); // disabled by default + ui->labelGXSId->setVisible(false); // disable by default ui->labelPositive->setVisible(value); ui->labelNegative->setVisible(value); ui->label_PosIcon_2->setVisible(value); @@ -223,3 +223,22 @@ void IdentityWidget::pbAdd_clicked() emit addButtonClicked(); } +uint32_t IdentityWidget::getReputation() const +{ + RsReputationInfo info; + // Use the logic to fetch reputation + if (rsReputations->getReputationInfo(RsGxsId(_group_info.mMeta.mGroupId), _group_info.mPgpId, info)) { + return info.mFriendsPositiveVotes; + } + return 0; // Default if no info found +} + +void IdentityWidget::mousePressEvent(QMouseEvent *event) +{ + if (event->button() == Qt::RightButton) { + emit addButtonClicked(); + } else { + emit clicked(); + } + FlowLayoutItem::mousePressEvent(event); +} diff --git a/retroshare-gui/src/gui/People/IdentityWidget.h b/retroshare-gui/src/gui/People/IdentityWidget.h index 03f980683..6030fc227 100644 --- a/retroshare-gui/src/gui/People/IdentityWidget.h +++ b/retroshare-gui/src/gui/People/IdentityWidget.h @@ -63,9 +63,15 @@ public: const QString nickname() const { return _nickname; } const QString gxsId() const { return _gxsId; } const QImage avatar() const { return _avatar; } + + uint32_t getReputation() const; signals: void addButtonClicked(); + void clicked(); + +protected: + virtual void mousePressEvent(QMouseEvent *event) override; private slots: void pbAdd_clicked(); diff --git a/retroshare-gui/src/gui/People/PeopleDialog.cpp b/retroshare-gui/src/gui/People/PeopleDialog.cpp index ad7c955de..4a40dc0b2 100644 --- a/retroshare-gui/src/gui/People/PeopleDialog.cpp +++ b/retroshare-gui/src/gui/People/PeopleDialog.cpp @@ -30,6 +30,8 @@ #include "gui/Identity/IdDialog.h" #include "gui/MainWindow.h" #include "gui/common/FilesDefs.h" +#include "util/DateTime.h" +#include "util/qtthreadsutils.h" #include "retroshare/rspeers.h" #include "retroshare/rsidentity.h" @@ -40,8 +42,11 @@ #include "retroshare/rsids.h" #include +#include +#include #include #include +#include /****** * #define PEOPLE_DIALOG_DEBUG 1 @@ -66,11 +71,12 @@ PeopleDialog::PeopleDialog(QWidget *parent) //mCirclesBroadcastBase = new RsGxsUpdateBroadcastBase(rsGxsCircles, this); //connect(mCirclesBroadcastBase, SIGNAL(fillDisplay(bool)), this, SLOT(updateCirclesDisplay(bool))); - tabWidget->removeTab(1); - //hide circle flow widget not functional yet - pictureFlowWidgetExternal->hide(); - widgetExternal->hide(); + //hide circle flow widget not functional more + switchButton->hide(); //disable this, to enable the circles flow widget + + UsagePage = new UsageStatistics(this); + detailsStackedWidget->addWidget(UsagePage); //need erase QtCreator Layout first(for Win) delete idExternal->layout(); @@ -79,6 +85,14 @@ PeopleDialog::PeopleDialog(QWidget *parent) _flowLayoutExt = new FlowLayout(idExternal); _flowLayoutInt = new FlowLayout(idInternal); + // Setup circlesTreeWidget + circlesTreeWidget->header()->resizeSection(0, 160); + circlesTreeWidget->header()->resizeSection(1, 80); + circlesTreeWidget->header()->resizeSection(2, 60); + + connect(circlesTreeWidget, &QTreeWidget::itemClicked, this, &PeopleDialog::onCircleTreeItemClicked); + connect(circlesTreeWidget, &QTreeWidget::customContextMenuRequested, this, &PeopleDialog::onCircleTreeContextMenuRequested); + {//First Get Item created in Qt Designer for External int count = idExternal->children().count(); for (int curs = 0; curs < count; ++curs){ @@ -114,6 +128,15 @@ PeopleDialog::PeopleDialog(QWidget *parent) QObject::connect(pictureFlowWidgetInternal, SIGNAL(dropEventOccurs(QDropEvent*)), this, SLOT(pf_dropEventOccursInt(QDropEvent*))); pictureFlowWidgetInternal->setMinimumHeight(60); pictureFlowWidgetInternal->setSlideSizeRatio(4/4.0); + + connect(filterLineEdit, SIGNAL(textChanged(QString)), this, SLOT(filterChanged(QString))); + connect(inviteButton, SIGNAL(clicked()), this, SLOT(sendInvite())); + connect(switchButton, SIGNAL(clicked()), this, SLOT(toggleStackedPage())); + connect(statsButton, SIGNAL(clicked()), this, SLOT(toggledetailsStackedPage())); + connect(detailsStackedWidget, &QStackedWidget::currentChanged, this, &PeopleDialog::onDetailsPageChanged); + connect(ownOpinion_CB, SIGNAL(currentIndexChanged(int)), this, SLOT(modifyReputation())); + connect(backButton, &QPushButton::clicked, this, &PeopleDialog::onBackClicked); + connect(joinLeaveCircleButton, &QPushButton::clicked, this, &PeopleDialog::requestJoinLeaveCircle); QByteArray geometryExt = Settings->valueFromGroup("PeopleDialog", "SplitterExtState", QByteArray()).toByteArray(); if (geometryExt.isEmpty() == false) { @@ -124,7 +147,21 @@ PeopleDialog::PeopleDialog(QWidget *parent) splitterInternal->restoreState(geometryInt); } + /* Thumbnails (index 0) stretch to fill space; details (index 1) stays compact */ + splitterExternal->setStretchFactor(0, 1); + splitterExternal->setStretchFactor(1, 0); + + // Create the sort menu + QMenu *sortMenu = new QMenu(this); + sortMenu->addAction(tr("Sort by Name"), this, SLOT(sortByName())); + sortMenu->addAction(tr("Sort by Popularity"), this, SLOT(sortByPopularity())); + + // Assign the menu + filterButton->setMenu(sortMenu); + filterButton->setPopupMode(QToolButton::InstantPopup); + reloadAll(); + showNoneSelected(); } @@ -144,127 +181,264 @@ void PeopleDialog::updateDisplay(bool complete) reloadAll(); } +static QString getHumanReadableDuration(uint32_t seconds) +{ + if(seconds < 60) + return QString(QObject::tr("%1 seconds ago")).arg(seconds) ; + else if(seconds < 120) + return QString(QObject::tr("%1 minute ago")).arg(seconds/60) ; + else if(seconds < 3600) + return QString(QObject::tr("%1 minutes ago")).arg(seconds/60) ; + else if(seconds < 7200) + return QString(QObject::tr("%1 hour ago")).arg(seconds/3600) ; + else if(seconds < 24*3600) + return QString(QObject::tr("%1 hours ago")).arg(seconds/3600) ; + else if(seconds < 2*24*3600) + return QString(QObject::tr("%1 day ago")).arg(seconds/86400) ; + else + return QString(QObject::tr("%1 days ago")).arg(seconds/86400) ; +} + void PeopleDialog::reloadAll() { - /* Update identity list */ - requestIdList(); - requestCirclesList(); + /* Update identity list */ + requestIdList(); + requestCirclesList(); - /* grab all ids */ - std::list friend_pgpIds; - std::list all_pgpIds; - std::list::iterator it; + std::list friend_pgpIds; + rsPeers->getGPGAcceptedList(friend_pgpIds); - std::set friend_set; + // 1. Collect widgets in a list for sorting + QList toSort; - rsPeers->getGPGAcceptedList(friend_pgpIds); - //rsPeers->getGPGAllList(all_pgpIds); + for(std::list::iterator it = friend_pgpIds.begin(); it != friend_pgpIds.end(); ++it) { + RsPeerDetails details; + if(rsPeers->getGPGDetails(*it, details)) { + std::map::iterator itFound = _pgp_identity_widgets.find(*it); + + IdentityWidget *widget = nullptr; + if(itFound == _pgp_identity_widgets.end()) { + // Create new if not exists + widget = new IdentityWidget(); + _pgp_identity_widgets[*it] = widget; + QObject::connect(widget, SIGNAL(addButtonClicked()), this, SLOT(iw_AddButtonClickedInt())); + QObject::connect(widget, SIGNAL(flowLayoutItemDropped(QList,bool&)), this, SLOT(fl_flowLayoutItemDroppedInt(QList,bool&))); + } else { + widget = itFound->second; + } + widget->updateData(details); + toSort.append(widget); + } + } - for(it = friend_pgpIds.begin(); it != friend_pgpIds.end(); ++it) { - RsPeerDetails details; - if(rsPeers->getGPGDetails(*it, details)){ - std::map::iterator itFound; - if((itFound=_pgp_identity_widgets.find(*it)) == _pgp_identity_widgets.end()) { - IdentityWidget *new_item = new IdentityWidget(); - new_item->updateData(details) ; - _pgp_identity_widgets[*it] = new_item ; + // 2. Sort alphabetically + std::sort(toSort.begin(), toSort.end(), [](IdentityWidget* a, IdentityWidget* b) { + return a->getName().compare(b->getName(), Qt::CaseInsensitive) < 0; + }); - QObject::connect(new_item, SIGNAL(addButtonClicked()), this, SLOT(iw_AddButtonClickedInt())); - QObject::connect(new_item, SIGNAL(flowLayoutItemDropped(QList,bool&)), this, SLOT(fl_flowLayoutItemDroppedInt(QList,bool&))); - _flowLayoutInt->addWidget(new_item); - } else {//if((itFound=_pgp_identity_widgets.find(gdItem.mPgpId)) == _pgp_identity_widgets.end()) - IdentityWidget *idWidget = itFound->second; - - idWidget->updateData(details) ; - }//else ((itFound=_pgp_identity_widgets.find(gdItem.mPgpId)) == _pgp_identity_widgets.end()) - - friend_set.insert(*it); - }//if(rsPeers->getGPGDetails(*it, details)) - }//for(it = friend_pgpIds.begin(); it != friend_pgpIds.end(); ++it) - - for(it = all_pgpIds.begin(); it != all_pgpIds.end(); ++it) { - if(friend_set.end() != friend_set.find(*it)) { - // already added as a friend. - continue; - }//if(friend_set.end() != friend_set.find(*it)) - - RsPeerDetails details; - if (rsPeers->getGPGDetails(*it, details)) { - std::map::iterator itFound; - if((itFound=_pgp_identity_widgets.find(*it)) == _pgp_identity_widgets.end()) { - IdentityWidget *new_item = new IdentityWidget(); - new_item->updateData(details) ; - _pgp_identity_widgets[*it] = new_item ; - - QObject::connect(new_item, SIGNAL(addButtonClicked()), this, SLOT(iw_AddButtonClickedInt())); - QObject::connect(new_item, SIGNAL(flowLayoutItemDropped(QList,bool&)), this, SLOT(fl_flowLayoutItemDroppedInt(QList,bool&))); - _flowLayoutInt->addWidget(new_item); - } else {//if((itFound=_pgp_identity_widgets.find(gdItem.mPgpId)) == _pgp_identity_widgets.end()) - IdentityWidget *idWidget = itFound->second; - - idWidget->updateData(details) ; - }//else ((itFound=_pgp_identity_widgets.find(gdItem.mPgpId)) == _pgp_identity_widgets.end()) - }//if(rsPeers->getGPGDetails(*it, details)) - }//for(it = all_pgpIds.begin(); it != all_pgpIds.end(); ++it) + // 3. Add to layout in sorted order + for(auto* w : toSort) { + _flowLayoutInt->addWidget(w); + } + + filterChanged(filterLineEdit->text()); } void PeopleDialog::insertIdList(uint32_t token) { - std::cerr << "**** In insertIdList() ****" << std::endl; + std::cerr << "**** In insertIdList() ****" << std::endl; + std::vector gdataVector; + if (!rsIdentity->getGroupData(token, gdataVector)) { + std::cerr << "PeopleDialog::insertIdList() Error getting GroupData"; + std::cerr << std::endl; - std::vector gdataVector; - std::vector::iterator gdIt; + return; + } - if (!rsIdentity->getGroupData(token, gdataVector)) { - std::cerr << "PeopleDialog::insertIdList() Error getting GroupData"; - std::cerr << std::endl; + // Declare the list at the TOP of the function so it is available everywhere + QList toSort; - return; - }//if (!rsIdentity->getGroupData(token, gdataVector)) + for (auto const& gdItem : gdataVector) { + RsPeerDetails details; + bool bGotDetail = gdItem.mPgpKnown && rsPeers->getGPGDetails(gdItem.mPgpId, details); - //RsPgpId ownPgpId = rsPeers->getGPGOwnId(); + RsGxsId gxsId(gdItem.mMeta.mGroupId); + std::map::iterator itFound = _gxs_identity_widgets.find(gxsId); + + IdentityWidget *widget = nullptr; + if(itFound == _gxs_identity_widgets.end()) { + widget = new IdentityWidget(); + _gxs_identity_widgets[gxsId] = widget; + QObject::connect(widget, SIGNAL(addButtonClicked()), this, SLOT(iw_AddButtonClickedExt())); + QObject::connect(widget, SIGNAL(flowLayoutItemDropped(QList,bool&)), this, SLOT(fl_flowLayoutItemDroppedExt(QList,bool&))); + connect(widget, SIGNAL(clicked()), this, SLOT(onIdentitySelected())); + //connect(widget, SIGNAL(widgetSelected(IdentityWidget*)), this, SLOT(onIdentitySelected(IdentityWidget*))); + } else { + widget = itFound->second; + } - /* Insert items */ - int i=0 ; - for (gdIt = gdataVector.begin(); gdIt != gdataVector.end(); ++gdIt){ - RsGxsIdGroup gdItem = (*gdIt); - bool bGotDetail = false; + if (bGotDetail) widget->updateData(gdItem, details); + else widget->updateData(gdItem); - RsPeerDetails details; - if (gdItem.mPgpKnown) { - bGotDetail = rsPeers->getGPGDetails(gdItem.mPgpId, details); - }//if (gdItem.mPgpKnown) + toSort.append(widget); + } - std::map::iterator itFound; - if((itFound=_gxs_identity_widgets.find(RsGxsId(gdItem.mMeta.mGroupId))) == _gxs_identity_widgets.end()) { - std::cerr << "Loading data vector identity GXS ID = " << gdItem.mMeta.mGroupId << ", i="<< i << std::endl; + // Sort and add to layout + std::sort(toSort.begin(), toSort.end(), [](IdentityWidget* a, IdentityWidget* b) { + return a->getName().compare(b->getName(), Qt::CaseInsensitive) < 0; + }); - IdentityWidget *new_item = new IdentityWidget(); - if (bGotDetail) { - new_item->updateData(gdItem, details); - } else {//if (bGotDetail) - new_item->updateData(gdItem); - }//else (bGotDetail) - _gxs_identity_widgets[RsGxsId(gdItem.mMeta.mGroupId)] = new_item ; + for(auto* w : toSort) { + _flowLayoutExt->addWidget(w); + } - QObject::connect(new_item, SIGNAL(addButtonClicked()), this, SLOT(iw_AddButtonClickedExt())); - QObject::connect(new_item, SIGNAL(flowLayoutItemDropped(QList,bool&)), this, SLOT(fl_flowLayoutItemDroppedExt(QList,bool&))); - _flowLayoutExt->addWidget(new_item); - ++i ; - } else {//if((itFound=_gxs_identity_widgets.find(RsGxsId(gdItem.mMeta.mGroupId))) == _gxs_identity_widgets.end()) + filterChanged(filterLineEdit->text()); +} - std::cerr << "Updating data vector identity GXS ID = " << gdItem.mMeta.mGroupId << std::endl; - IdentityWidget *idWidget = itFound->second; +void PeopleDialog::populateCirclesTree(const std::list& circles) +{ + for (const auto& gsItem : circles) { + RsGxsCircleDetails details; + bool hasDetails = rsGxsCircles && rsGxsCircles->getCircleDetails(RsGxsCircleId(gsItem.mGroupId), details); - if (bGotDetail) { - idWidget->updateData(gdItem, details) ; - } else {//if (bGotDetail) - idWidget->updateData(gdItem) ; - }//else (bGotDetail) + auto itFound = _ext_circles_widgets.find(gsItem.mGroupId); + if (itFound == _ext_circles_widgets.end()) { + std::cerr << "PeopleDialog::populateCirclesTree() add new GroupId: " << gsItem.mGroupId + << " GroupName: " << gsItem.mGroupName << std::endl; - }//else ((itFound=_gxs_identity_widgets.find(RsGxsId(gdItem.mMeta.mGroupId))) == _gxs_identity_widgets.end())) - }//for (gdIt = gdataVector.begin(); gdIt != gdataVector.end(); ++gdIt) + CircleWidget *gitem = new CircleWidget(); + QObject::connect(gitem, SIGNAL(flowLayoutItemDropped(QList,bool&)), this, SLOT(fl_flowLayoutItemDroppedExt(QList,bool&))); + QObject::connect(gitem, SIGNAL(askForGXSIdentityWidget(RsGxsId)), this, SLOT(cw_askForGXSIdentityWidget(RsGxsId))); + QObject::connect(gitem, SIGNAL(askForPGPIdentityWidget(RsPgpId)), this, SLOT(cw_askForPGPIdentityWidget(RsPgpId))); + QObject::connect(gitem, SIGNAL(imageUpdated()), this, SLOT(cw_imageUpdatedExt())); + QObject::connect(gitem, &CircleWidget::clicked, this, &PeopleDialog::onCircleSelected); + + gitem->updateData(gsItem, details); + _ext_circles_widgets[gsItem.mGroupId] = gitem; + + QPixmap pixmap = gitem->getImage(); + pictureFlowWidgetExternal->addSlide(pixmap); + _extListCir << gitem; + } else { + CircleWidget *cirWidget = itFound->second; + cirWidget->updateData(gsItem, details); + } + + // Update or create QTreeWidgetItem in circlesTreeWidget + QString groupIdStr = QString::fromStdString(gsItem.mGroupId.toStdString()); + QTreeWidgetItem *treeItem = nullptr; + for (int i = 0; i < circlesTreeWidget->topLevelItemCount(); ++i) { + if (circlesTreeWidget->topLevelItem(i)->data(0, Qt::UserRole).toString() == groupIdStr) { + treeItem = circlesTreeWidget->topLevelItem(i); + break; + } + } + + if (!treeItem) { + treeItem = new QTreeWidgetItem(circlesTreeWidget); + treeItem->setData(0, Qt::UserRole, groupIdStr); + treeItem->setIcon(0, QIcon(":/icons/png/circles.png")); + } + + // Calculate total member count + uint32_t validMembers = 0; + if (hasDetails) { + for (const auto& [mId, flags] : details.mSubscriptionFlags) { + const bool invited = (flags & GXS_EXTERNAL_CIRCLE_FLAGS_IN_ADMIN_LIST); + const bool subscrb = (flags & GXS_EXTERNAL_CIRCLE_FLAGS_SUBSCRIBED); + if (invited || subscrb) { + validMembers++; + } + } + if (validMembers == 0) { + if (!details.mAllowedNodes.empty()) { + validMembers = details.mAllowedNodes.size(); + } else { + validMembers = details.mAllowedGxsIds.size(); + } + } + } + + treeItem->setText(0, QString::fromUtf8(gsItem.mGroupName.c_str())); + treeItem->setText(1, QString::number(validMembers)); + + // Populate sub-items for circle members in treeItem + if (hasDetails) { + if (!details.mSubscriptionFlags.empty()) { + // Remove stale children + while (treeItem->childCount() > 0) { + delete treeItem->takeChild(0); + } + + for (const auto& [gxsId, flags] : details.mSubscriptionFlags) { + const bool invited = (flags & GXS_EXTERNAL_CIRCLE_FLAGS_IN_ADMIN_LIST); + const bool subscrb = (flags & GXS_EXTERNAL_CIRCLE_FLAGS_SUBSCRIBED); + if (!(invited || subscrb)) continue; + + QTreeWidgetItem *childItem = new QTreeWidgetItem(treeItem); + childItem->setData(0, Qt::UserRole, QString::fromStdString(gxsId.toStdString())); + + // Identity name & icon + QString name; + QIcon icon; + auto idWidgetIt = _gxs_identity_widgets.find(gxsId); + if (idWidgetIt != _gxs_identity_widgets.end()) { + name = idWidgetIt->second->nickname(); + icon = QIcon(QPixmap::fromImage(idWidgetIt->second->avatar())); + } else { + RsIdentityDetails idDetails; + if (rsIdentity && rsIdentity->getIdDetails(gxsId, idDetails)) { + name = QString::fromUtf8(idDetails.mNickname.c_str()); + } else { + name = tr("[Unknown]"); + } + icon = QIcon(GxsIdDetails::makeDefaultIcon(gxsId)); + } + + childItem->setText(0, name); + childItem->setIcon(0, icon); + + // Status (Invited, Subscribed, Member) + QString statusText; + if (subscrb && invited) { + statusText = tr("Member"); + } else if (invited) { + statusText = tr("Invited"); + } else if (subscrb) { + statusText = tr("Subscribed"); + } else { + statusText = tr("Pending"); + } + + childItem->setText(1, statusText); + } + } else if (!details.mAllowedNodes.empty()) { + while (treeItem->childCount() > 0) { + delete treeItem->takeChild(0); + } + + for (const auto& nodePgpId : details.mAllowedNodes) { + QTreeWidgetItem *childItem = new QTreeWidgetItem(treeItem); + childItem->setData(0, Qt::UserRole, QString::fromStdString(nodePgpId.toStdString())); + + RsPeerDetails pDetails; + QString name; + if (rsPeers && rsPeers->getGPGDetails(nodePgpId, pDetails)) { + name = QString::fromUtf8(pDetails.name.c_str()); + } else { + name = QString::fromStdString(nodePgpId.toStdString()); + } + + childItem->setText(0, name); + childItem->setIcon(0, QIcon(":/icons/png/person.png")); + childItem->setText(1, tr("Member")); + } + } + } + } + + filterChanged(filterLineEdit->text()); } void PeopleDialog::insertCircles(uint32_t token) @@ -272,99 +446,9 @@ void PeopleDialog::insertCircles(uint32_t token) std::cerr << "PeopleDialog::insertExtCircles(token==" << token << ")" << std::endl; std::list gSummaryList; - std::list::iterator gsIt; - - if (!rsGxsCircles->getGroupSummary(token,gSummaryList)) - { - std::cerr << "PeopleDialog::insertExtCircles() Error getting GroupSummary"; - std::cerr << std::endl; - - return; - } - - for(gsIt = gSummaryList.begin(); gsIt != gSummaryList.end(); ++gsIt) { - RsGroupMetaData gsItem = (*gsIt); - - RsGxsCircleDetails details ; - if(!rsGxsCircles->getCircleDetails(RsGxsCircleId(gsItem.mGroupId), details)) - { - std::cerr << "(EE) Cannot get details for circle id " << gsItem.mGroupId << ". Circle item is not created!" << std::endl; - continue ; - } - - if (details.mCircleType != RsGxsCircleType::EXTERNAL) - { - std::map::iterator itFound; - if((itFound=_int_circles_widgets.find(gsItem.mGroupId)) == _int_circles_widgets.end()) - { - std::cerr << "PeopleDialog::insertExtCircles() add new Internal GroupId: " << gsItem.mGroupId; - std::cerr << " GroupName: " << gsItem.mGroupName; - std::cerr << std::endl; - - CircleWidget *gitem = new CircleWidget() ; - QObject::connect(gitem, SIGNAL(flowLayoutItemDropped(QList,bool&)), this, SLOT(fl_flowLayoutItemDroppedInt(QList,bool&))); - QObject::connect(gitem, SIGNAL(askForGXSIdentityWidget(RsGxsId)), this, SLOT(cw_askForGXSIdentityWidget(RsGxsId))); - QObject::connect(gitem, SIGNAL(askForPGPIdentityWidget(RsPgpId)), this, SLOT(cw_askForPGPIdentityWidget(RsPgpId))); - QObject::connect(gitem, SIGNAL(imageUpdated()), this, SLOT(cw_imageUpdatedInt())); - gitem->updateData( gsItem, details ); - _int_circles_widgets[gsItem.mGroupId] = gitem ; - - _flowLayoutInt->addWidget(gitem); - - QPixmap pixmap = gitem->getImage(); - pictureFlowWidgetInternal->addSlide( pixmap ); - _intListCir << gitem; - } - else - { - std::cerr << "PeopleDialog::insertExtCircles() Update GroupId: " << gsItem.mGroupId; - std::cerr << " GroupName: " << gsItem.mGroupName; - std::cerr << std::endl; - - CircleWidget *cirWidget = itFound->second; - cirWidget->updateData( gsItem, details ); - - //int index = _intListCir.indexOf(cirWidget); - //QPixmap pixmap = cirWidget->getImage(); - //pictureFlowWidgetInternal->setSlide(index, pixmap); - } - } - else - { - std::map::iterator itFound; - if((itFound=_ext_circles_widgets.find(gsItem.mGroupId)) == _ext_circles_widgets.end()) { - std::cerr << "PeopleDialog::insertExtCircles() add new GroupId: " << gsItem.mGroupId; - std::cerr << " GroupName: " << gsItem.mGroupName; - std::cerr << std::endl; - - CircleWidget *gitem = new CircleWidget() ; - QObject::connect(gitem, SIGNAL(flowLayoutItemDropped(QList,bool&)), this, SLOT(fl_flowLayoutItemDroppedExt(QList,bool&))); - QObject::connect(gitem, SIGNAL(askForGXSIdentityWidget(RsGxsId)), this, SLOT(cw_askForGXSIdentityWidget(RsGxsId))); - QObject::connect(gitem, SIGNAL(askForPGPIdentityWidget(RsPgpId)), this, SLOT(cw_askForPGPIdentityWidget(RsPgpId))); - QObject::connect(gitem, SIGNAL(imageUpdated()), this, SLOT(cw_imageUpdatedExt())); - gitem->updateData( gsItem, details ); - _ext_circles_widgets[gsItem.mGroupId] = gitem ; - - _flowLayoutExt->addWidget(gitem); - - QPixmap pixmap = gitem->getImage(); - pictureFlowWidgetExternal->addSlide( pixmap ); - _extListCir << gitem; - } - else - { - std::cerr << "PeopleDialog::insertExtCircles() Update GroupId: " << gsItem.mGroupId; - std::cerr << " GroupName: " << gsItem.mGroupName; - std::cerr << std::endl; - - CircleWidget *cirWidget = itFound->second; - cirWidget->updateData( gsItem, details ); - - //int index = _extListCir.indexOf(cirWidget); - //QPixmap pixmap = cirWidget->getImage(); - //pictureFlowWidgetExternal->setSlide(index, pixmap); - } - } + if (rsGxsCircles && rsGxsCircles->getGroupSummary(token, gSummaryList)) + { + populateCirclesTree(gSummaryList); } } @@ -388,15 +472,30 @@ void PeopleDialog::requestCirclesList() { std::cerr << "Requesting Circles list..." << std::endl; - if (!mCirclesQueue) return; + if (mCirclesQueue) { + mCirclesQueue->cancelActiveRequestTokens(PD_CIRCLES); - mCirclesQueue->cancelActiveRequestTokens(PD_CIRCLES); + RsTokReqOptions opts; + opts.mReqType = GXS_REQUEST_TYPE_GROUP_META; - RsTokReqOptions opts; - opts.mReqType = GXS_REQUEST_TYPE_GROUP_META; + uint32_t token; + mCirclesQueue->requestGroupInfo(token, RS_TOKREQ_ANSTYPE_SUMMARY, opts, PD_CIRCLES); + } - uint32_t token; - mCirclesQueue->requestGroupInfo(token, RS_TOKREQ_ANSTYPE_SUMMARY, opts, PD_CIRCLES); + // Directly query rsGxsCircles asynchronously to guarantee circle loading + RsThread::async([this]() + { + std::list circles; + if (!rsGxsCircles || !rsGxsCircles->getCirclesSummaries(circles)) { + std::cerr << "PeopleDialog::requestCirclesList() failed to get circles summaries" << std::endl; + return; + } + + RsQThreadUtils::postToObject([this, circles]() + { + populateCirclesTree(circles); + }, this); + }); } void PeopleDialog::updateCirclesDisplay(bool) @@ -637,18 +736,23 @@ void PeopleDialog::sendMessage() void PeopleDialog::sendInvite() { - QAction *action = - qobject_cast(QObject::sender()); - if (action) { - QString data = action->data().toString(); + RsGxsId gxs_id; - RsGxsId gxs_id = RsGxsId(data.toStdString());; - - MessageComposer::sendInvite(gxs_id,false); - - } - + // Check if triggered by a Context Menu Action + QAction *action = qobject_cast(QObject::sender()); + if (action) { + QString data = action->data().toString(); + gxs_id = RsGxsId(data.toStdString()); + } + // Otherwise, check if it was the UI Button + else { + gxs_id = mCurrentSelectedId; + } + // Execute the invite if the ID is valid + if (!gxs_id.isNull()) { + MessageComposer::sendInvite(gxs_id, false); + } } void PeopleDialog::addtoContacts() @@ -1121,3 +1225,749 @@ void PeopleDialog::populatePictureFlowInt() }//for (it=_int_circles_widgets.begin(); it!=_int_circles_widgets.end(); ++it) pictureFlowWidgetInternal->setSlideSizeRatio(4/4.0); } + +void PeopleDialog::filterChanged(const QString &text) +{ + Qt::CaseSensitivity cs = Qt::CaseInsensitive; + + if (_selectionMode == SelectionMode::Circle) { + // When a circle is selected, filter only among its members (shown in A) + auto it = _ext_circles_widgets.find(_selectedCircleId); + if (it != _ext_circles_widgets.end()) { + const auto& allowed = it->second->circleDetails().mAllowedGxsIds; + for (auto& [id, w] : _gxs_identity_widgets) { + bool isMember = (allowed.count(id) > 0); + bool matches = text.isEmpty() || w->getName().contains(text, cs); + w->setVisible(isMember && matches); + } + } + } else { + // No selection / identity selected: filter all identity widgets + for (auto& [id, w] : _gxs_identity_widgets) + w->setVisible(text.isEmpty() || w->getName().contains(text, cs)); + } + + // Always filter the PGP identity widgets (internal tab, kept for completeness) + for (auto& [id, w] : _pgp_identity_widgets) + w->setVisible(text.isEmpty() || w->getName().contains(text, cs)); + + // Filter circlesTreeWidget items based on search text + for (int i = 0; i < circlesTreeWidget->topLevelItemCount(); ++i) { + QTreeWidgetItem *item = circlesTreeWidget->topLevelItem(i); + QString name = item->text(0); + item->setHidden(!text.isEmpty() && !name.contains(text, cs)); + } + + // CRITICAL: Invalidate layouts so Qt recomputes positions (not just repaints) + _flowLayoutExt->invalidate(); + _flowLayoutInt->invalidate(); +} + +void PeopleDialog::sortByName() +{ + clearAllSelections(); + clearPerson(); + applySortAndFilter(true); // true for name +} + +void PeopleDialog::sortByPopularity() +{ + clearAllSelections(); + clearPerson(); + applySortAndFilter(false); // false for popularity +} + +void PeopleDialog::applySortAndFilter(bool byName) +{ + clearAllSelections(); + + QString filterText = filterLineEdit->text(); + Qt::CaseSensitivity cs = Qt::CaseInsensitive; + + // We process internal and external layouts separately + auto sortLayout = [&](FlowLayout* layout, auto& widgetMap) { + // 1. Collect widgets from the map + QList list; + for (auto const& [id, w] : widgetMap) { + list << w; + + // Uncheck/Deselect the item here --- + w->setIsSelected(false); + } + + // 2. Sort the list + std::sort(list.begin(), list.end(), [byName](QWidget* a, QWidget* b) { + auto* idA = qobject_cast(a); + auto* idB = qobject_cast(b); + if (!idA || !idB) return false; + + if (byName) { + return idA->getName().compare(idB->getName(), Qt::CaseInsensitive) < 0; + } else { + // Popularity (Higher reputation first) + return idA->getReputation() > idB->getReputation(); + } + }); + + // 3. Clear and Re-add to layout in sorted order + for (QWidget* w : list) { + // Apply filter while we are at it + QString name = qobject_cast(w)->getName(); + w->setVisible(name.contains(filterText, cs)); + + layout->addWidget(w); + } + }; + + sortLayout(_flowLayoutInt, _pgp_identity_widgets); + sortLayout(_flowLayoutExt, _gxs_identity_widgets); +} + +void PeopleDialog::clearAllSelections() +{ + // Tell every identity widget to visually uncheck & reset current state + for (auto const& [id, w] : _gxs_identity_widgets) { + if (w) { + w->setIsSelected(false); + w->setIsCurrent(false); + w->setStyleSheet(QString()); + } + } + for (auto const& [id, w] : _pgp_identity_widgets) { + if (w) { + w->setIsSelected(false); + w->setIsCurrent(false); + w->setStyleSheet(QString()); + } + } + + // Tell every circle widget to visually uncheck + for (auto const& [id, w] : _ext_circles_widgets) { + if (w) { + w->setIsSelected(false); + w->setStyleSheet(QString()); + } + } + for (auto const& [id, w] : _int_circles_widgets) { + if (w) { + w->setIsSelected(false); + w->setStyleSheet(QString()); + } + } +} + +void PeopleDialog::loadIdentityLabels(const RsGxsIdGroup& data) +{ + RsPgpId ownPgpId = rsPeers->getGPGOwnId(); + + lineEdit_PublishTS->setText(DateTime::formatDateTime(data.mMeta.mPublishTs)); + lineEdit_KeyId->setText(QString::fromStdString(data.mMeta.mGroupId.toStdString())); + + if(data.mPgpKnown) + lineEdit_GpgId->setText(QString::fromStdString(data.mPgpId.toStdString())); + else + lineEdit_GpgId->setText(QString::fromStdString(data.mPgpId.toStdString()) + tr(" [unverified]")); + + // Update Visibility for GPG specific items + bool hasPgp = !data.mPgpId.isNull(); + autoBanIdentities_CB->setVisible(hasPgp); + //banoption_label->setVisible(hasPgp); + lineEdit_GpgId->setVisible(hasPgp); + label_GpgId->setVisible(hasPgp); + + time_t now = time(NULL); + lineEdit_LastUsed->setText(getHumanReadableDuration(now - data.mLastUsageTS)); + headerTextLabel_Person->setText(QString::fromUtf8(data.mMeta.mGroupName.c_str())); + + // Avatar Loading + QPixmap pixmap; + if(data.mImage.mSize == 0 || !GxsIdDetails::loadPixmapFromData(data.mImage.mData, data.mImage.mSize, pixmap, GxsIdDetails::LARGE)) + pixmap = GxsIdDetails::makeDefaultIcon(RsGxsId(data.mMeta.mGroupId), GxsIdDetails::LARGE); + + avatarLabel->setPixmap(pixmap.scaled(64, 64, Qt::KeepAspectRatio, Qt::SmoothTransformation)); + + if (data.mPgpKnown) + { + RsPeerDetails details; + rsPeers->getGPGDetails(data.mPgpId, details); + lineEdit_GpgName->setText(QString::fromUtf8(details.name.c_str())); + } + else + { + if (data.mMeta.mGroupFlags & RSGXSID_GROUPFLAG_REALID_kept_for_compatibility) + lineEdit_GpgName->setText(tr("[Unknown node]")); + else + lineEdit_GpgName->setText(tr("Anonymous Id")); + } + + if(data.mPgpId.isNull()) + { + lineEdit_GpgId->hide() ; + label_GpgId->hide() ; + } + else + { + lineEdit_GpgId->show() ; + label_GpgId->show() ; + } + + if(data.mPgpKnown) + { + lineEdit_GpgName->show() ; + label_GpgName->show() ; + } + else + { + lineEdit_GpgName->hide() ; + label_GpgName->hide() ; + } + + // Type and Node logic + + bool isLinkedToOwnPgpId = (data.mPgpKnown && (data.mPgpId == ownPgpId)) ; + bool isOwnId = (data.mMeta.mSubscribeFlags & GXS_SERV::GROUP_SUBSCRIBE_ADMIN); + + if(isOwnId) + if (isLinkedToOwnPgpId) + lineEdit_Type->setText(tr("Identity owned by you, linked to your Retroshare node")) ; + else + if (data.mMeta.mGroupFlags & (GXS_SERV::FLAG_PRIVACY_PRIVATE | RSGXSID_GROUPFLAG_REALID)) + lineEdit_Type->setText(tr("Identity owned by you, linked to your Retroshare node but not yet validated")) ; + else + lineEdit_Type->setText(tr("Anonymous identity, owned by you")) ; + else if (data.mMeta.mGroupFlags & RSGXSID_GROUPFLAG_REALID_kept_for_compatibility) + { + if (data.mPgpKnown) + if (rsPeers->isGPGAccepted(data.mPgpId)) + lineEdit_Type->setText(tr("Linked to a friend Retroshare node")) ; + else + lineEdit_Type->setText(tr("Linked to a known Retroshare node")) ; + else + lineEdit_Type->setText(tr("Linked to unknown Retroshare node")) ; + } + else + { + lineEdit_Type->setText(tr("Anonymous identity")) ; + } + + autoBanIdentities_CB->setChecked(rsReputations->isNodeBanned(data.mPgpId)); + + /* now fill in the reputation information */ + + RsReputationInfo info; + rsReputations->getReputationInfo(RsGxsId(data.mMeta.mGroupId),data.mPgpId,info) ; + + QString frep_string ; + if(info.mFriendsPositiveVotes > 0) frep_string += QString::number(info.mFriendsPositiveVotes) + tr(" positive ") ; + if(info.mFriendsNegativeVotes > 0) frep_string += QString::number(info.mFriendsNegativeVotes) + tr(" negative ") ; + + if(info.mFriendsPositiveVotes==0 && info.mFriendsNegativeVotes==0) + frep_string = tr("No votes from friends") ; + + neighborNodesOpinion_TF->setText(frep_string) ; + + label_positive->setText(QString::number(info.mFriendsPositiveVotes)); + label_negative->setText(QString::number(info.mFriendsNegativeVotes)); + + switch(info.mOverallReputationLevel) + { + case RsReputationLevel::LOCALLY_POSITIVE: + overallOpinion_TF->setText(tr("Positive")); break; + case RsReputationLevel::LOCALLY_NEGATIVE: + overallOpinion_TF->setText(tr("Negative (Banned by you)")); break; + case RsReputationLevel::REMOTELY_POSITIVE: + overallOpinion_TF->setText(tr("Positive (according to your friends)")); + break; + case RsReputationLevel::REMOTELY_NEGATIVE: + overallOpinion_TF->setText(tr("Negative (according to your friends)")); + break; + case RsReputationLevel::NEUTRAL: // fallthrough + default: + overallOpinion_TF->setText(tr("Neutral")) ; break ; + } + + switch(info.mOwnOpinion) + { + case RsOpinion::NEGATIVE: ownOpinion_CB->setCurrentIndex(0); break; + case RsOpinion::NEUTRAL : ownOpinion_CB->setCurrentIndex(1); break; + case RsOpinion::POSITIVE: ownOpinion_CB->setCurrentIndex(2); break; + default: + std::cerr << "Unexpected value in own opinion: " + << static_cast(info.mOwnOpinion) << std::endl; + break; + } + + // Toggle Edit/Invite buttons + editButton->setVisible(isOwnId); + inviteButton->setVisible(!isOwnId); +} + +void PeopleDialog::onIdentitySelected() +{ + IdentityWidget* widget = qobject_cast(sender()); + if (widget) { + // Save the ID for the Invite Button to use later + mCurrentSelectedId = RsGxsId(widget->groupInfo().mMeta.mGroupId); + + // Update the Usage Statistics page + UsageStatistics* usageWidget = qobject_cast(UsagePage); + if (usageWidget) + usageWidget->setUsageData(widget->groupInfo()); + + clearAllSelections(); + widget->setIsSelected(true); + + // Drive the state machine + showIdentitySelected(mCurrentSelectedId); + } +} + +void PeopleDialog::clearPerson() +{ + headerTextLabel_Person->setText(tr("People")); + avatarLabel->clear(); + avatarLabel->setPixmap(FilesDefs::getPixmapFromQtResourcePath(":/icons/png/people.png")); + + lineEdit_GpgId->clear(); + lineEdit_KeyId->clear(); + lineEdit_Type->clear(); + lineEdit_GpgName->clear(); + lineEdit_PublishTS->clear(); + lineEdit_LastUsed->clear(); + neighborNodesOpinion_TF->clear(); + overallOpinion_TF->clear(); + label_positive->clear(); + label_negative->clear(); + ownOpinion_CB->setCurrentIndex(1); + autoBanIdentities_CB->setChecked(false); +} + +void PeopleDialog::toggleStackedPage() +{ + if (widgetExternal->currentIndex() == 0) { + widgetExternal->setCurrentIndex(1); + } else { + widgetExternal->setCurrentIndex(0); + } +} + +void PeopleDialog::toggledetailsStackedPage() +{ + // Toggle between usage stats (2) and details (0 for identity, 1 for circle) + if (detailsStackedWidget->currentIndex() == 2) { + detailsStackedWidget->setCurrentIndex(_selectionMode == SelectionMode::Circle ? 1 : 0); + } else { + detailsStackedWidget->setCurrentIndex(2); + } +} + +void PeopleDialog::onDetailsPageChanged(int index) +{ + if (index == 2) { + statsButton->setText(tr("View Details")); + } else { + statsButton->setText(tr("View Stats")); + } +} + +void PeopleDialog::modifyReputation() +{ + RsGxsId id(lineEdit_KeyId->text().toStdString()); + + RsOpinion op; + + switch(ownOpinion_CB->currentIndex()) + { + case 0: op = RsOpinion::NEGATIVE; break; + case 1: op = RsOpinion::NEUTRAL ; break; + case 2: op = RsOpinion::POSITIVE; break; + default: + std::cerr << "Wrong value from opinion combobox. Bug??" << std::endl; + return; + } + rsReputations->setOwnOpinion(id,op); + + return; +} + +void PeopleDialog::setVoteControlsVisible(bool visible) +{ + label_YourOpinion->setVisible(visible); + ownOpinion_CB->setVisible(visible); + autoBanIdentities_CB->setVisible(visible); + neighborNodesOpinion_LB->setVisible(visible); + neighborNodesOpinion_TF->setVisible(visible); + overallOpinion_TF->setVisible(visible); + label_PosIcon->setVisible(visible); + label_positive->setVisible(visible); + line_Opinion->setVisible(visible); + label_NegIcon->setVisible(visible); + label_negative->setVisible(visible); +} + +// ─── State machine ──────────────────────────────────────────────────────────── + +void PeopleDialog::showNoneSelected() +{ + _selectionMode = SelectionMode::None; + _selectedGxsId = RsGxsId(); + _selectedCircleId = RsGxsGroupId(); + + clearAllSelections(); + + backButton->setVisible(false); + joinLeaveCircleButton->setVisible(false); + setVoteControlsVisible(true); + + // Rule 2c: show all identities in A, nothing in B, and all circles in C. + for (auto& [id, w] : _gxs_identity_widgets) { + w->setVisible(true); + } + + clearPerson(); + detailsStackedWidget->setCurrentIndex(0); + + // Show all circles in circlesTreeWidget + { + QSignalBlocker blocker(circlesTreeWidget); + for (int i = 0; i < circlesTreeWidget->topLevelItemCount(); ++i) { + QTreeWidgetItem *item = circlesTreeWidget->topLevelItem(i); + item->setHidden(false); + item->setForeground(0, QBrush()); + QFont font = item->font(0); + font.setBold(false); + item->setFont(0, font); + } + circlesTreeWidget->clearSelection(); + } + + _flowLayoutExt->invalidate(); +} + +void PeopleDialog::showIdentitySelected(const RsGxsId& id) +{ + _selectionMode = SelectionMode::Identity; + _selectedGxsId = id; + + clearAllSelections(); + + backButton->setVisible(true); + joinLeaveCircleButton->setVisible(false); + setVoteControlsVisible(true); + + // Rule 2a: show identity details in B and the circles the identity belongs to in C + for (auto& [gid, w] : _gxs_identity_widgets) { + const bool isSelected = (gid == id); + w->setVisible(true); + w->setIsSelected(isSelected); + w->setIsCurrent(isSelected); + w->setStyleSheet(isSelected + ? QStringLiteral("border: 2px solid #2196F3; border-radius: 4px;") + : QString()); + } + + auto it = _gxs_identity_widgets.find(id); + if (it != _gxs_identity_widgets.end()) + loadIdentityLabels(it->second->groupInfo()); + detailsStackedWidget->setCurrentIndex(0); + + // Show ONLY circles the identity belongs to in C (and highlight green) + { + QSignalBlocker blocker(circlesTreeWidget); + for (int i = 0; i < circlesTreeWidget->topLevelItemCount(); ++i) { + QTreeWidgetItem *item = circlesTreeWidget->topLevelItem(i); + RsGxsGroupId circleId(item->data(0, Qt::UserRole).toString().toStdString()); + auto cit = _ext_circles_widgets.find(circleId); + bool member = false; + if (cit != _ext_circles_widgets.end()) { + RsGxsCircleDetails cDet; + if (rsGxsCircles && rsGxsCircles->getCircleDetails(RsGxsCircleId(circleId), cDet)) { + cit->second->updateData(cit->second->groupInfo(), cDet); + } else { + cDet = cit->second->circleDetails(); + } + + auto subIt = cDet.mSubscriptionFlags.find(id); + if (subIt != cDet.mSubscriptionFlags.end()) { + const uint32_t flags = subIt->second; + const bool invited = (flags & GXS_EXTERNAL_CIRCLE_FLAGS_IN_ADMIN_LIST); + const bool subscrb = (flags & GXS_EXTERNAL_CIRCLE_FLAGS_SUBSCRIBED); + member = (invited || subscrb); + } + if (!member) { + member = (cDet.mAllowedGxsIds.count(id) > 0); + } + } + item->setHidden(!member); + if (member) { + item->setForeground(0, QBrush(QColor("#4CAF50"))); + QFont font = item->font(0); + font.setBold(true); + item->setFont(0, font); + } + } + circlesTreeWidget->clearSelection(); + } + + _flowLayoutExt->invalidate(); +} + +void PeopleDialog::showCircleSelected(const RsGxsGroupId& circleId) +{ + _selectionMode = SelectionMode::Circle; + _selectedCircleId = circleId; + + clearAllSelections(); + + backButton->setVisible(true); + editButton->setVisible(false); + inviteButton->setVisible(false); + joinLeaveCircleButton->setVisible(true); + setVoteControlsVisible(false); + + auto it = _ext_circles_widgets.find(circleId); + if (it == _ext_circles_widgets.end()) return; + + CircleWidget* cw = it->second; + cw->setIsSelected(true); + + // Re-fetch latest circle details directly from rsGxsCircles + RsGxsCircleDetails details; + if (rsGxsCircles && rsGxsCircles->getCircleDetails(RsGxsCircleId(circleId), details)) { + cw->updateData(cw->groupInfo(), details); + } else { + details = cw->circleDetails(); + } + + // Ensure all circle members exist in _gxs_identity_widgets + for (const auto& [memberId, flags] : details.mSubscriptionFlags) { + const bool invited = (flags & GXS_EXTERNAL_CIRCLE_FLAGS_IN_ADMIN_LIST); + const bool subscrb = (flags & GXS_EXTERNAL_CIRCLE_FLAGS_SUBSCRIBED); + if (!(invited || subscrb)) continue; + + if (_gxs_identity_widgets.find(memberId) == _gxs_identity_widgets.end()) { + std::vector idsInfo; + if (rsIdentity && rsIdentity->getIdentitiesInfo({memberId}, idsInfo) && !idsInfo.empty()) { + IdentityWidget *w = new IdentityWidget(); + w->updateData(idsInfo[0]); + QObject::connect(w, SIGNAL(addButtonClicked()), this, SLOT(iw_AddButtonClickedExt())); + QObject::connect(w, SIGNAL(flowLayoutItemDropped(QList,bool&)), this, SLOT(fl_flowLayoutItemDroppedExt(QList,bool&))); + QObject::connect(w, &IdentityWidget::clicked, this, &PeopleDialog::onIdentitySelected); + _gxs_identity_widgets[memberId] = w; + _flowLayoutExt->addWidget(w); + } + } + } + + // Rule 2b: show in A the identities in that circle (with color code for member) + for (auto& [gid, w] : _gxs_identity_widgets) { + bool isMember = false; + auto subIt = details.mSubscriptionFlags.find(gid); + if (subIt != details.mSubscriptionFlags.end()) { + const uint32_t flags = subIt->second; + const bool invited = (flags & GXS_EXTERNAL_CIRCLE_FLAGS_IN_ADMIN_LIST); + const bool subscrb = (flags & GXS_EXTERNAL_CIRCLE_FLAGS_SUBSCRIBED); + isMember = (invited || subscrb); + } + if (!isMember) { + isMember = (details.mAllowedGxsIds.count(gid) > 0); + } + + w->setVisible(isMember); + w->setIsSelected(false); + w->setIsCurrent(false); + w->setStyleSheet(isMember + ? QStringLiteral("border: 2px solid #4CAF50; border-radius: 4px;") + : QString()); + } + + // Circle details in B + loadCircleLabels(circleId); + detailsStackedWidget->setCurrentIndex(1); + + // All circles visible in C, select current + { + QSignalBlocker blocker(circlesTreeWidget); + QString targetIdStr = QString::fromStdString(circleId.toStdString()); + for (int i = 0; i < circlesTreeWidget->topLevelItemCount(); ++i) { + QTreeWidgetItem *item = circlesTreeWidget->topLevelItem(i); + item->setHidden(false); + if (item->data(0, Qt::UserRole).toString() == targetIdStr) { + circlesTreeWidget->setCurrentItem(item); + } + } + } + + _flowLayoutExt->invalidate(); +} + +void PeopleDialog::loadCircleLabels(const CircleWidget* cw) +{ + if (!cw) return; + loadCircleLabels(cw->groupInfo().mGroupId); +} + +void PeopleDialog::loadCircleLabels(const RsGxsGroupId& circleId) +{ + auto it = _ext_circles_widgets.find(circleId); + if (it == _ext_circles_widgets.end()) return; + + RsGxsCircleDetails details; + if (rsGxsCircles && rsGxsCircles->getCircleDetails(RsGxsCircleId(circleId), details)) { + it->second->updateData(it->second->groupInfo(), details); + } else { + details = it->second->circleDetails(); + } + + const RsGroupMetaData& info = it->second->groupInfo(); + + headerTextLabel_Person->setText(QString::fromUtf8(info.mGroupName.c_str())); + circleNameEdit->setText(QString::fromUtf8(info.mGroupName.c_str())); + circleIdEdit->setText(QString::fromStdString(info.mGroupId.toStdString())); + + avatarLabel->setPixmap(FilesDefs::getPixmapFromQtResourcePath(":/icons/png/circles.png")); + + const bool isExternal = (details.mCircleType == RsGxsCircleType::EXTERNAL); + circleTypeEdit->setText(isExternal ? tr("External") : tr("Personal")); + + uint32_t validMembers = 0; + for (const auto& [mId, flags] : details.mSubscriptionFlags) { + const bool invited = (flags & GXS_EXTERNAL_CIRCLE_FLAGS_IN_ADMIN_LIST); + const bool subscrb = (flags & GXS_EXTERNAL_CIRCLE_FLAGS_SUBSCRIBED); + if (invited || subscrb) { + validMembers++; + } + } + if (validMembers == 0) { + validMembers = details.mAllowedGxsIds.size(); + } + + circleMemberCountEdit->setText(QString::number(validMembers)); + + // Join / leave button label & state + const bool isMember = details.mAmIAllowed; + const bool isRequested = _requestedCircles.count(circleId) > 0; + + if (isMember) { + joinLeaveCircleButton->setEnabled(true); + joinLeaveCircleButton->setText(tr("Leave Circle")); + joinLeaveCircleButton->setToolTip(tr("Cancel circle membership")); + } else if (isRequested) { + joinLeaveCircleButton->setEnabled(false); + joinLeaveCircleButton->setText(tr("Requested")); + joinLeaveCircleButton->setToolTip(tr("Membership request sent")); + } else { + joinLeaveCircleButton->setEnabled(true); + joinLeaveCircleButton->setText(tr("Request")); + joinLeaveCircleButton->setToolTip(tr("Request to join")); + } +} + +void PeopleDialog::onCircleSelected() +{ + CircleWidget* w = qobject_cast(sender()); + if (!w) return; + clearAllSelections(); + w->setIsSelected(true); + showCircleSelected(w->groupInfo().mGroupId); +} + +void PeopleDialog::onCircleTreeItemClicked(QTreeWidgetItem *item, int /*column*/) +{ + if (!item) return; + + // Ignore right-clicks so right-clicking opens context menu without switching view + if (QApplication::mouseButtons() & Qt::RightButton) return; + + QString groupIdStr = item->data(0, Qt::UserRole).toString(); + if (!groupIdStr.isEmpty()) { + RsGxsGroupId groupId(groupIdStr.toStdString()); + showCircleSelected(groupId); + } +} + +void PeopleDialog::onCircleTreeContextMenuRequested(const QPoint &pos) +{ + QTreeWidgetItem *item = circlesTreeWidget->itemAt(pos); + if (!item) return; + + QString groupIdStr = item->data(0, Qt::UserRole).toString(); + RsGxsGroupId groupId(groupIdStr.toStdString()); + + // Check if local user is Admin of this circle + bool isAdmin = false; + auto it = _ext_circles_widgets.find(groupId); + if (it != _ext_circles_widgets.end()) { + isAdmin = (it->second->groupInfo().mSubscribeFlags & GXS_SERV::GROUP_SUBSCRIBE_ADMIN) != 0; + } + + QMenu menu(this); + QAction *actAction = nullptr; + if (isAdmin) { + actAction = menu.addAction(FilesDefs::getIconFromQtResourcePath(":/icons/png/pencil-edit-button.png"), tr("Edit Circle")); + } else { + actAction = menu.addAction(FilesDefs::getIconFromQtResourcePath(":/images/info16.png"), tr("View Details")); + } + + QAction *actJoinLeave = menu.addAction(tr("Request to Join / Leave")); + + menu.addSeparator(); + QAction *actCopyLink = menu.addAction(tr("Copy retroshare link")); + + QAction *selected = menu.exec(circlesTreeWidget->mapToGlobal(pos)); + if (selected == actAction) { + CreateCircleDialog dlg; + dlg.editExistingId(groupId, true, !isAdmin); // readonly = false if Admin (Edit Circle), readonly = true if Non-Admin (View Details) + dlg.exec(); + } else if (selected == actJoinLeave) { + _selectedCircleId = groupId; + requestJoinLeaveCircle(); + } else if (selected == actCopyLink) { + RsGxsCircleDetails details; + if (rsGxsCircles && rsGxsCircles->getCircleDetails(RsGxsCircleId(groupId), details)) { + QList urls; + RetroShareLink link = RetroShareLink::createCircle(RsGxsCircleId(groupId), QString::fromUtf8(details.mCircleName.c_str())); + urls.push_back(link); + RSLinkClipboard::copyLinks(urls); + } + } +} + +void PeopleDialog::onBackClicked() +{ + clearAllSelections(); + showNoneSelected(); +} + +void PeopleDialog::requestJoinLeaveCircle() +{ + auto it = _ext_circles_widgets.find(_selectedCircleId); + if (it == _ext_circles_widgets.end()) return; + + const RsGxsCircleDetails& details = it->second->circleDetails(); + const bool isMember = details.mAmIAllowed; + + // Pick the first own GXS identity (same approach used elsewhere in the dialog) + std::list ownIds; + rsIdentity->getOwnIds(ownIds); + if (ownIds.empty()) { + std::cerr << "PeopleDialog::requestJoinLeaveCircle() No own identity available." << std::endl; + return; + } + const RsGxsId ownId = ownIds.front(); + + if (isMember) { + rsGxsCircles->cancelCircleMembership(ownId, RsGxsCircleId(_selectedCircleId)); + _requestedCircles.erase(_selectedCircleId); + joinLeaveCircleButton->setEnabled(true); + joinLeaveCircleButton->setText(tr("Request")); + joinLeaveCircleButton->setToolTip(tr("Request to join")); + } else { + rsGxsCircles->requestCircleMembership(ownId, RsGxsCircleId(_selectedCircleId)); + _requestedCircles.insert(_selectedCircleId); + joinLeaveCircleButton->setEnabled(false); + joinLeaveCircleButton->setText(tr("Requested")); + joinLeaveCircleButton->setToolTip(tr("Membership request sent")); + } +} + diff --git a/retroshare-gui/src/gui/People/PeopleDialog.h b/retroshare-gui/src/gui/People/PeopleDialog.h index 808c61a26..b3e02bf3c 100644 --- a/retroshare-gui/src/gui/People/PeopleDialog.h +++ b/retroshare-gui/src/gui/People/PeopleDialog.h @@ -26,6 +26,7 @@ #include "gui/People/CircleWidget.h" #include "gui/People/IdentityWidget.h" +#include "gui/Identity/UsageStatistics.h" #include "gui/gxs/RsGxsUpdateBroadcastPage.h" #include "util/TokenQueue.h" @@ -59,7 +60,7 @@ class PeopleDialog : public MainPage, public Ui::PeopleDialog, public TokenRespo void insertCircles(uint32_t token) ; protected: - // Derives from RsGxsUpdateBroadcastPage + // Derives from MainPage virtual void updateDisplay(bool complete); //End RsGxsUpdateBroadcastPage @@ -88,16 +89,51 @@ private slots: void personDetails(); void sendInvite(); void addtoContacts(); - + void filterChanged(const QString &text); + void sortByName(); + void sortByPopularity(); + void clearAllSelections(); + void onIdentitySelected(); + void onCircleSelected(); // circle widget clicked → circle-selected state + void onCircleTreeItemClicked(QTreeWidgetItem *item, int column); + void onCircleTreeContextMenuRequested(const QPoint &pos); + void onBackClicked(); // back button → return to no-selection state + void clearPerson(); + void toggleStackedPage(); + void toggledetailsStackedPage(); + void onDetailsPageChanged(int index); + void modifyReputation(); + void requestJoinLeaveCircle(); // join/leave button in circle details page private: void reloadAll(); void populatePictureFlowExt(); void populatePictureFlowInt(); + void applySortAndFilter(bool); + void loadIdentityLabels(const RsGxsIdGroup& data); + void populateCirclesTree(const std::list& circles); + void setVoteControlsVisible(bool visible); + + // Selection state machine + enum class SelectionMode { None, Identity, Circle }; + void showNoneSelected(); + void showIdentitySelected(const RsGxsId& id); + void showCircleSelected(const RsGxsGroupId& circleId); + void loadCircleLabels(const CircleWidget* cw); + void loadCircleLabels(const RsGxsGroupId& circleId); TokenQueue *mIdentityQueue; TokenQueue *mCirclesQueue; //RsGxsUpdateBroadcastBase *mCirclesBroadcastBase ; + RsGxsId mCurrentSelectedId; // Store the ID of the person currently clicked + QWidget *UsagePage; + + // Selection state + SelectionMode _selectionMode = SelectionMode::None; + RsGxsId _selectedGxsId; + RsGxsGroupId _selectedCircleId; + bool _inSelectionUpdate = false; + std::set _requestedCircles; FlowLayout *_flowLayoutExt; std::map _gxs_identity_widgets ; diff --git a/retroshare-gui/src/gui/People/PeopleDialog.ui b/retroshare-gui/src/gui/People/PeopleDialog.ui index 416496a8a..c7be8e7cc 100644 --- a/retroshare-gui/src/gui/People/PeopleDialog.ui +++ b/retroshare-gui/src/gui/People/PeopleDialog.ui @@ -6,8 +6,8 @@ 0 0 - 727 - 524 + 988 + 529 @@ -51,6 +51,45 @@ People + + + + + + Search... + + + + + + + + + + + :/icons/mail/filter24.png:/icons/mail/filter24.png + + + + + + + View circles + + + ... + + + + :/icons/png/circles.png:/icons/png/circles.png + + + true + + + + + @@ -60,7 +99,7 @@ 0 - 250 + 80 @@ -71,39 +110,613 @@ 0 0 - 701 - 248 + 962 + 78 - - - - - - - 0 - 0 - - - - Drag your circles or people to each other. - - - Qt::AlignCenter - - - - - - - true - - - - + + + 1 + + + + + + + + 0 + 0 + + + + Drag your circles or people to each other. + + + Qt::AlignCenter + + + + + + + true + + + + + + + + + 0 + + + 0 + + + 0 + + + + + 3 + + + + + + + + 0 + 0 + + + + + 128 + 128 + + + + + 128 + 128 + + + + QFrame::StyledPanel + + + QFrame::Raised + + + Your Avatar + + + true + + + Qt::AlignCenter + + + + + + + + 0 + 0 + + + + Edit Identity + + + + + + + + 0 + 0 + + + + Send Invite + + + + + + + <html><head/><body><p><span style=" font-family:'ui-monospace','SFMono-Regular','Menlo','Monaco','Consolas','Liberation Mono','Courier New','monospace'; font-size:12px; color:#101010; background-color:rgba(198,236,203,0.376471);">Request to join</span></p></body></html> + + + Request + + + + + + + + + 6 + + + 2 + + + + + + 34 + 34 + + + + + + + :/icons/png/thumbs-up.png + + + true + + + + + + + + 16 + + + + Positive votes + + + 0 + + + Qt::AlignCenter + + + + + + + Qt::Vertical + + + + + + + + 34 + 34 + + + + + + + :/icons/png/thumbs-down.png + + + true + + + + + + + + 16 + + + + Negative votes + + + 0 + + + Qt::AlignCenter + + + + + + + + + Qt::Vertical + + + + 118 + 17 + + + + + + + + View Stats + + + + + + + + + 9 + + + + + + 22 + + + + People + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Back + + + + :/icons/png/arrow-left.png:/icons/png/arrow-left.png + + + + + + + + + + 0 + 0 + + + + + 0 + 299 + + + + 0 + + + + + + + Votes: + + + true + + + + + + + <html><head/><body><p>Average opinion of neighbor nodes about this identity. Negative is bad,</p><p>positive is good. Zero is neutral.</p></body></html> + + + true + + + + + + + Identity ID : + + + + + + + true + + + + + + + Owner node ID : + + + + + + + Last used: + + + + + + + true + + + true + + + + + + + Qt::CustomContextMenu + + + false + + + false + + + true + + + + Circle Name + + + + + Members + + + + + + + + Type: + + + + + + + Auto-Ban all identities signed by the same node + + + Auto-Ban profile + + + + + + + true + + + + + + + Your opinion: + + + + + + + <html><head/><body><p><span style=" font-family:'Sans'; font-size:9pt;">Your own opinion about an identity rules the visibility of that identity for yourself and your friend nodes. Your own opinion is shared among friends and used to compute a reputation score: If your opinion about an identity is neutral, the reputation score is the difference between friend's positive and negative opinions. If not, your own opinion gives the score.</span></p><p><span style=" font-family:'Sans'; font-size:9pt;">The overall score is used in chat lobbies, forums and channels to decide on the actions to take for each specific identity. When the overall score is lower than -1, the identity is banned, which prevents all messages and forums/channels authored by this identity to be forwarded, both ways. Some forums also have special anti-spam flags that require a non negative reputation level, making them more sensitive to bad opinions. Banned identities gradually lose their activity and eventually disappear (after 5 days).</span></p><p><span style=" font-family:'Sans'; font-size:9pt;">You can change the thresholds and the time of inactivity to delete identities in preferences -&gt; people. </span></p></body></html> + + + + 22 + 22 + + + + + Negative + + + + :/icons/png/thumbs-down.png:/icons/png/thumbs-down.png + + + + + Neutral + + + + :/icons/png/thumbs-neutral.png:/icons/png/thumbs-neutral.png + + + + + Positive + + + + :/icons/png/thumbs-up.png:/icons/png/thumbs-up.png + + + + + + + + true + + + true + + + + + + + Created on : + + + + + + + <html><head/><body><p>Overall reputation score, accounting for yours and your friends'.</p><p>Negative is bad, positive is good. Zero is neutral. If the score is too low,</p><p>the identity is flagged as bad, and will be filtered out in forums, chat lobbies,</p><p>channels, etc.</p></body></html> + + + true + + + + + + + true + + + true + + + + + + + Owner node name : + + + + + + + Qt::Vertical + + + + 20 + 1 + + + + + + + + true + + + + + + + + + + + Circle Name: + + + + + + + true + + + + + + + Circle ID: + + + + + + + true + + + + + + + Type: + + + + + + + true + + + + + + + Members: + + + + + + + true + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + @@ -129,7 +742,7 @@ 0 - 250 + 80 @@ -140,8 +753,8 @@ 0 0 - 701 - 248 + 962 + 164 @@ -183,6 +796,11 @@ + + LineEditClear + QLineEdit +
gui/common/LineEditClear.h
+
PictureFlow QWidget @@ -195,9 +813,20 @@
gui/common/FlowLayout.h
1
+ + ElidedLabel + QLabel +
gui/common/ElidedLabel.h
+ 1 +
+ + RSComboBox + QComboBox +
gui/common/RSComboBox.h
+
- + diff --git a/retroshare-gui/src/gui/common/FlowLayout.cpp b/retroshare-gui/src/gui/common/FlowLayout.cpp index dfb9ea5a7..b99e4a134 100644 --- a/retroshare-gui/src/gui/common/FlowLayout.cpp +++ b/retroshare-gui/src/gui/common/FlowLayout.cpp @@ -596,8 +596,10 @@ QSize FlowLayout::minimumSize() const { QSize size; QLayoutItem *item; - foreach (item, m_itemList) + foreach (item, m_itemList) { + if (item->widget() && !item->widget()->isVisible()) continue; size = size.expandedTo(item->minimumSize()); + } QMargins margins = contentsMargins(); size += QSize(margins.left() + margins.right(), margins.top() + margins.bottom()); @@ -681,6 +683,8 @@ int FlowLayout::doLayout(const QRect &rect, bool testOnly) const for (int curs=0; curswidget(); + // Skip hidden widgets so they don't leave empty gaps in the flow + if (!wid || !wid->isVisible()) continue; int spaceX = horizontalSpacing(); if (spaceX == -1) spaceX = wid->style()->layoutSpacing( diff --git a/retroshare-gui/src/retroshare-gui.pro b/retroshare-gui/src/retroshare-gui.pro index cdb9cef3d..0c268c9f3 100644 --- a/retroshare-gui/src/retroshare-gui.pro +++ b/retroshare-gui/src/retroshare-gui.pro @@ -111,6 +111,7 @@ CONFIG += gxschannels CONFIG += posted CONFIG += gxsgui CONFIG += gxscircles +CONFIG += gxspeople # Other Disabled Bits. #CONFIG += framecatcher @@ -1277,22 +1278,24 @@ identities { gui/Identity/IdentityListModel.h \ gui/Identity/IdEditDialog.h \ gui/Identity/IdDetailsDialog.h \ + gui/Identity/UsageStatistics.h \ FORMS += gui/Identity/IdDialog.ui \ gui/Identity/IdEditDialog.ui \ gui/Identity/IdDetailsDialog.ui \ + gui/Identity/UsageStatistics.ui \ SOURCES += \ gui/Identity/IdDialog.cpp \ gui/Identity/IdentityListModel.cpp \ gui/Identity/IdEditDialog.cpp \ gui/Identity/IdDetailsDialog.cpp \ + gui/Identity/UsageStatistics.cpp \ } gxscircles { DEFINES += RS_USE_CIRCLES -# DEFINES += RS_USE_NEW_PEOPLE_DIALOG HEADERS += \ gui/Circles/CirclesDialog.h \ @@ -1304,27 +1307,24 @@ gxscircles { SOURCES += \ gui/Circles/CirclesDialog.cpp \ gui/Circles/CreateCircleDialog.cpp \ +} + +gxspeople { + # Enable the new PeopleDialog by adding CONFIG += rs_new_people_dialog + # on the qmake command line or at the top of this .pro file. + DEFINES += RS_USE_NEW_PEOPLE HEADERS += gui/People/PeopleDialog.h HEADERS += gui/People/CircleWidget.h HEADERS += gui/People/IdentityWidget.h - FORMS += gui/People/PeopleDialog.ui + FORMS += gui/People/PeopleDialog.ui FORMS += gui/People/CircleWidget.ui FORMS += gui/People/IdentityWidget.ui - SOURCES += gui/People/PeopleDialog.cpp + SOURCES += gui/People/PeopleDialog.cpp SOURCES += gui/People/CircleWidget.cpp SOURCES += gui/People/IdentityWidget.cpp - -#HEADERS += gui/People/IdentityItem.h -#HEADERS += gui/People/CircleItem.h -#HEADERS += gui/People/GroupListView.h -#SOURCES += gui/People/GroupListView.cpp -#SOURCES += gui/People/IdentityItem.cpp -#SOURCES += gui/People/CircleItem.cpp - - }