From 4dcaed98483dcb6332291514cf9d8a136c951ea6 Mon Sep 17 00:00:00 2001 From: defnax <9952056+defnax@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:53:34 +0200 Subject: [PATCH 1/3] Flatview for channels & boards comments --- .../src/gui/gxs/CommentItemWidget.cpp | 215 +++++++++++ .../src/gui/gxs/CommentItemWidget.h | 103 +++++ .../src/gui/gxs/CommentItemWidget.ui | 243 ++++++++++++ .../src/gui/gxs/FlatViewCommentWidget.cpp | 354 ++++++++++++++++++ .../src/gui/gxs/FlatViewCommentWidget.h | 90 +++++ .../src/gui/gxs/FlatViewCommentWidget.ui | 85 +++++ .../src/gui/gxs/GxsCommentDialog.cpp | 149 +++++++- retroshare-gui/src/gui/gxs/GxsCommentDialog.h | 14 + .../src/gui/gxs/GxsCommentDialog.ui | 150 +++++--- retroshare-gui/src/retroshare-gui.pro | 6 + 10 files changed, 1353 insertions(+), 56 deletions(-) create mode 100644 retroshare-gui/src/gui/gxs/CommentItemWidget.cpp create mode 100644 retroshare-gui/src/gui/gxs/CommentItemWidget.h create mode 100644 retroshare-gui/src/gui/gxs/CommentItemWidget.ui create mode 100644 retroshare-gui/src/gui/gxs/FlatViewCommentWidget.cpp create mode 100644 retroshare-gui/src/gui/gxs/FlatViewCommentWidget.h create mode 100644 retroshare-gui/src/gui/gxs/FlatViewCommentWidget.ui diff --git a/retroshare-gui/src/gui/gxs/CommentItemWidget.cpp b/retroshare-gui/src/gui/gxs/CommentItemWidget.cpp new file mode 100644 index 000000000..ed126eae6 --- /dev/null +++ b/retroshare-gui/src/gui/gxs/CommentItemWidget.cpp @@ -0,0 +1,215 @@ +/******************************************************************************* + * retroshare-gui/src/gui/gxs/CommentItemWidget.cpp * + * * + * Copyright 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 "CommentItemWidget.h" +#include "ui_CommentItemWidget.h" +#include "GxsIdDetails.h" +#include "util/DateTime.h" +#include "util/qtthreadsutils.h" +#include "util/HandleRichText.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +CommentItemWidget::CommentItemWidget(QWidget *parent) + : QWidget(parent), ui(new Ui::CommentItemWidget), mViewRepliesButton(nullptr), + mLevel(0), mUpvoteActive(false), mDownvoteActive(false), mSelected(false), mRepliesExpanded(false), mReplyCount(0) +{ + ui->setupUi(this); + setupStyle(); + + setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Maximum); + + mViewRepliesButton = new QPushButton(this); + mViewRepliesButton->setFlat(true); + mViewRepliesButton->hide(); + connect(mViewRepliesButton, &QPushButton::clicked, this, &CommentItemWidget::on_viewRepliesButton_clicked); + + // Insert the view-replies button into the content layout, below the actions row + QVBoxLayout *contentLay = qobject_cast(ui->contentLayout); + if (contentLay) { + QHBoxLayout *repliesButtonLay = new QHBoxLayout(); + repliesButtonLay->setContentsMargins(0, 0, 0, 0); + repliesButtonLay->addWidget(mViewRepliesButton); + repliesButtonLay->addStretch(); + contentLay->addLayout(repliesButtonLay); + } +} + +CommentItemWidget::~CommentItemWidget() +{ + delete ui; +} + +void CommentItemWidget::setMsgId(const RsGxsMessageId &id) +{ + mMsgId = id; +} + +void CommentItemWidget::setAuthorId(const RsGxsId &id) +{ + mAuthorId = id; +} + +void CommentItemWidget::setupStyle() +{ + // Style the upvote/downvote buttons + ui->upvoteButton->setIcon(QIcon(":/icons/png/thumbs-up.png")); + ui->downvoteButton->setIcon(QIcon(":/icons/png/thumbs-down.png")); +} + +void CommentItemWidget::setAuthorName(const QString &name) +{ + mAuthorName = name; + ui->authorLabel->setText(name); +} + +void CommentItemWidget::setAuthorAvatar(const QPixmap &avatar) +{ + if (!avatar.isNull()) + ui->avatarLabel->setPixmap(avatar); +} + +void CommentItemWidget::setCommentText(const QString &text) +{ + mCommentText = text; + ui->commentTextLabel->setText(RsHtml().formatText(NULL, text, RSHTML_FORMATTEXT_EMBED_SMILEYS | RSHTML_FORMATTEXT_EMBED_LINKS)); +} + +void CommentItemWidget::setDateTime(const QString &datetime) +{ + ui->dateTimeLabel->setText(datetime); +} + +void CommentItemWidget::setScore(int score) +{ + ui->scoreLabel->setText(QString::number(score)); +} + +void CommentItemWidget::setUpvote(bool upvoted) +{ + mUpvoteActive = upvoted; + ui->upvoteButton->setChecked(upvoted); + if (upvoted) { + ui->downvoteButton->setChecked(false); + ui->downvoteButton->setStyleSheet(""); + } else { + ui->upvoteButton->setStyleSheet(""); + } +} + +void CommentItemWidget::setDownvote(bool downvoted) +{ + mDownvoteActive = downvoted; + ui->downvoteButton->setChecked(downvoted); + if (downvoted) { + ui->upvoteButton->setChecked(false); + ui->upvoteButton->setStyleSheet(""); + } else { + ui->downvoteButton->setStyleSheet(""); + } +} + +void CommentItemWidget::setReplyCount(int count) +{ + if (count > 0) { + ui->replyButton->setText(QString("Reply (%1)").arg(count)); + } else { + ui->replyButton->setText("Reply"); + } +} + +void CommentItemWidget::setLevel(int level) +{ + mLevel = level; + // Apply indentation for nested replies + int leftMargin = 6 + (level * 24); + layout()->setContentsMargins(leftMargin, 4, 6, 4); +} + +void CommentItemWidget::on_upvoteButton_clicked() +{ + setUpvote(!mUpvoteActive); + emit upvoteClicked(mMsgId); +} + +void CommentItemWidget::on_downvoteButton_clicked() +{ + setDownvote(!mDownvoteActive); + emit downvoteClicked(mMsgId); +} + +void CommentItemWidget::on_replyButton_clicked() +{ + emit replyClicked(mMsgId); +} + +void CommentItemWidget::on_authorLabel_linkActivated(const QString &link) +{ + Q_UNUSED(link); + emit authorClicked(mAuthorId); +} + +void CommentItemWidget::setViewRepliesCount(int count) +{ + mReplyCount = count; + if (count <= 0) { + mViewRepliesButton->hide(); + return; + } + mRepliesExpanded = false; + mViewRepliesButton->setIcon(QIcon(":/icons/png/down-arrow.png")); + mViewRepliesButton->setText(tr("View %1 repl%2").arg(count).arg(count == 1 ? "y" : "ies")); + mViewRepliesButton->show(); +} + +void CommentItemWidget::on_viewRepliesButton_clicked() +{ + mRepliesExpanded = !mRepliesExpanded; + if (mRepliesExpanded){ + mViewRepliesButton->setIcon(QIcon(":/icons/png/up-arrow.png")); + mViewRepliesButton->setText(tr("Hide %1 repl%2").arg(mReplyCount).arg(mReplyCount == 1 ? "y" : "ies")); + }else{ + mViewRepliesButton->setIcon(QIcon(":/icons/png/down-arrow.png")); + mViewRepliesButton->setText(tr("View %1 repl%2").arg(mReplyCount).arg(mReplyCount == 1 ? "y" : "ies")); + } + emit viewRepliesToggled(mMsgId, mRepliesExpanded); +} + +void CommentItemWidget::setSelected(bool selected) +{ + mSelected = selected; + if (selected) + setStyleSheet("QWidget#CommentItemWidget { background-color: #e8f0fe; border-left: 3px solid #3ea6ff; }"); + else + setStyleSheet("QWidget#CommentItemWidget { background-color: transparent; }"); +} + +void CommentItemWidget::mousePressEvent(QMouseEvent *event) +{ + QWidget::mousePressEvent(event); + emit commentSelected(mMsgId); +} diff --git a/retroshare-gui/src/gui/gxs/CommentItemWidget.h b/retroshare-gui/src/gui/gxs/CommentItemWidget.h new file mode 100644 index 000000000..9200b488a --- /dev/null +++ b/retroshare-gui/src/gui/gxs/CommentItemWidget.h @@ -0,0 +1,103 @@ +/******************************************************************************* + * retroshare-gui/src/gui/gxs/CommentItemWidget.h * + * * + * Copyright 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 COMMENT_ITEM_WIDGET_H +#define COMMENT_ITEM_WIDGET_H + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace Ui { +class CommentItemWidget; +} + +class CommentItemWidget : public QWidget +{ + Q_OBJECT + +public: + explicit CommentItemWidget(QWidget *parent = nullptr); + ~CommentItemWidget(); + + void setMsgId(const RsGxsMessageId &id); + RsGxsMessageId getMsgId() const { return mMsgId; } + void setAuthorId(const RsGxsId &id); + void setAuthorName(const QString &name); + void setAuthorAvatar(const QPixmap &avatar); + void setCommentText(const QString &text); + void setDateTime(const QString &datetime); + void setScore(int score); + void setUpvote(bool upvoted); + void setDownvote(bool downvoted); + void setReplyCount(int count); + void setLevel(int level); + void setViewRepliesCount(int count); + void setSelected(bool selected); + + int getLevel() const { return mLevel; } + QString getCommentText() const { return mCommentText; } + QString getAuthorName() const { return mAuthorName; } + RsGxsId getAuthorId() const { return mAuthorId; } + + signals: + void upvoteClicked(const RsGxsMessageId &msgId); + void downvoteClicked(const RsGxsMessageId &msgId); + void replyClicked(const RsGxsMessageId &msgId); + void authorClicked(const RsGxsId &authorId); + void viewRepliesToggled(const RsGxsMessageId &msgId, bool show); + void commentSelected(const RsGxsMessageId &msgId); + +protected: + void mousePressEvent(QMouseEvent *event) override; + +private slots: + void on_upvoteButton_clicked(); + void on_downvoteButton_clicked(); + void on_replyButton_clicked(); + void on_authorLabel_linkActivated(const QString &link); + void on_viewRepliesButton_clicked(); + +private: + void setupStyle(); + + Ui::CommentItemWidget *ui; + + QPushButton *mViewRepliesButton; + int mLevel; + bool mUpvoteActive; + bool mDownvoteActive; + bool mSelected; + bool mRepliesExpanded; + RsGxsMessageId mMsgId; + RsGxsId mAuthorId; + QString mCommentText; + QString mAuthorName; + int mReplyCount; +}; + +#endif // COMMENT_ITEM_WIDGET_H diff --git a/retroshare-gui/src/gui/gxs/CommentItemWidget.ui b/retroshare-gui/src/gui/gxs/CommentItemWidget.ui new file mode 100644 index 000000000..3b37fcdac --- /dev/null +++ b/retroshare-gui/src/gui/gxs/CommentItemWidget.ui @@ -0,0 +1,243 @@ + + + CommentItemWidget + + + + 0 + 0 + 500 + 98 + + + + + 6 + + + 4 + + + 6 + + + 4 + + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + 8 + + + + + + + + 40 + 40 + + + + + 40 + 40 + + + + + + + true + + + Qt::AlignCenter + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + 4 + + + + + 8 + + + + + Author Name + + + false + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + 2 hours ago + + + Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + + + + + Qt::Horizontal + + + + 0 + 0 + + + + + + + + + + Comment text goes here + + + true + + + true + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + + + + + 12 + + + + + + 24 + 24 + + + + + 32 + 32 + + + + Like + + + + + + true + + + + + + + 0 + + + + + + + + 24 + 24 + + + + + 32 + 32 + + + + Dislike + + + + + + true + + + + + + + + 60 + 28 + + + + Reply + + + + + + + Qt::Horizontal + + + + 0 + 0 + + + + + + + + + + + + + + + + diff --git a/retroshare-gui/src/gui/gxs/FlatViewCommentWidget.cpp b/retroshare-gui/src/gui/gxs/FlatViewCommentWidget.cpp new file mode 100644 index 000000000..48fe2fe03 --- /dev/null +++ b/retroshare-gui/src/gui/gxs/FlatViewCommentWidget.cpp @@ -0,0 +1,354 @@ +/******************************************************************************* + * retroshare-gui/src/gui/gxs/FlatViewCommentWidget.cpp * + * * + * Copyright 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 "FlatViewCommentWidget.h" +#include "CommentItemWidget.h" +#include "GxsIdDetails.h" +#include "util/DateTime.h" +#include "util/qtthreadsutils.h" + +#include +#include +#include + +#include + +#include "ui_FlatViewCommentWidget.h" + +static void fillCommentItemWidgetCallback(GxsIdDetailsType type, const RsIdentityDetails &details, QObject *object, const QVariant &/*data*/) +{ + CommentItemWidget *item = dynamic_cast(object); + if (!item) + return; + + switch (type) { + case GXS_ID_DETAILS_TYPE_DONE: + // getName() uses details.mNickname — the actual display name. + item->setAuthorName(GxsIdDetails::getNameForType(type, details)); + { + QPixmap avatar; + if (details.mAvatar.mSize > 0 && GxsIdDetails::loadPixmapFromData(details.mAvatar.mData, details.mAvatar.mSize, avatar)) + item->setAuthorAvatar(avatar); + else + item->setAuthorAvatar(GxsIdDetails::makeDefaultIcon(details.mId)); + } + break; + + case GXS_ID_DETAILS_TYPE_LOADING: + // Set generated default avatar so the slot is never blank while loading. + // Leave the name empty — no raw ID prefix shown. + if (!details.mId.isNull()) + item->setAuthorAvatar(GxsIdDetails::makeDefaultIcon(details.mId)); + break; + + case GXS_ID_DETAILS_TYPE_FAILED: + // Identity not available; show truncated ID as fallback, keep default icon. + if (!details.mId.isNull()) { + item->setAuthorName(QString::fromStdString(details.mId.toStdString().substr(0, 10)) + QStringLiteral("…")); + item->setAuthorAvatar(GxsIdDetails::makeDefaultIcon(details.mId)); + } + break; + + case GXS_ID_DETAILS_TYPE_BANNED: + item->setAuthorName(QObject::tr("[Banned]")); + if (!details.mId.isNull()) + item->setAuthorAvatar(GxsIdDetails::makeDefaultIcon(details.mId)); + break; + + case GXS_ID_DETAILS_TYPE_EMPTY: + default: + break; + } +} + +FlatViewCommentWidget::FlatViewCommentWidget(QWidget *parent) + : QWidget(parent), ui(new Ui::FlatViewCommentWidget), mCommentService(nullptr), + mSelectedWidget(nullptr) +{ + ui->setupUi(this); + + // Reuse the layout already defined in the .ui file — creating a second one + // would silently replace it and lose the trailing spacer. + mCommentsLayout = qobject_cast(ui->commentsScrollArea->widget()->layout()); + mCommentsLayout->setSpacing(8); +} + +FlatViewCommentWidget::~FlatViewCommentWidget() +{ + delete ui; +} + +void FlatViewCommentWidget::setCommentService(RsGxsCommentService *service) +{ + mCommentService = service; +} + +void FlatViewCommentWidget::setVoterId(const RsGxsId &id) +{ + mVoterId = id; +} + +void FlatViewCommentWidget::updateReplyCountButtons() +{ + for (auto it = mRepliesMap.begin(); it != mRepliesMap.end(); ++it) { + CommentItemWidget *parent = mCommentWidgets.value(it.key(), nullptr); + if (parent) + parent->setViewRepliesCount(it.value().size()); + } +} + +void FlatViewCommentWidget::clearComments() +{ + // Zero before deleting to avoid a dangling pointer if Qt emits signals during destruction + mSelectedWidget = nullptr; + + // Remove all comment widgets except the trailing stretch spacer + while (mCommentsLayout->count() > 1) { + QLayoutItem *item = mCommentsLayout->takeAt(0); + if (item && item->widget()) { + delete item->widget(); + } + delete item; + } + mCommentWidgets.clear(); + mRepliesMap.clear(); + mScoreMap.clear(); + mTimestampMap.clear(); +} + +void FlatViewCommentWidget::addComment(const RsGxsComment &comment, const RsGxsMessageId &parentId) +{ + CommentItemWidget *itemWidget = new CommentItemWidget(); + + itemWidget->setMsgId(comment.mMeta.mMsgId); + itemWidget->setAuthorId(comment.mMeta.mAuthorId); + itemWidget->setCommentText(QString::fromStdString(comment.mComment)); + itemWidget->setDateTime(DateTime::formatDateTime(comment.mMeta.mPublishTs)); + itemWidget->setScore(static_cast(comment.mUpVotes) - static_cast(comment.mDownVotes)); + + if (comment.mOwnVote == GXS_VOTE_UP) { + itemWidget->setUpvote(true); + } else if (comment.mOwnVote == GXS_VOTE_DOWN) { + itemWidget->setDownvote(true); + } + + mScoreMap[comment.mMeta.mMsgId] = comment.mScore; + mTimestampMap[comment.mMeta.mMsgId] = comment.mMeta.mPublishTs; + + int level = parentId.isNull() ? 0 : 1; + itemWidget->setLevel(level); + + // replies start hidden; the parent's "View X replies" button reveals them + if (level > 0) + itemWidget->hide(); + + mCommentWidgets[comment.mMeta.mMsgId] = itemWidget; + + // Vote buttons wired to doVote() which calls the comment service directly, + // mirroring GxsCommentTreeWidget::vote() + connect(itemWidget, &CommentItemWidget::upvoteClicked, this, [this](const RsGxsMessageId &msgId) { + doVote(msgId, true); + }); + connect(itemWidget, &CommentItemWidget::downvoteClicked, this, [this](const RsGxsMessageId &msgId) { + doVote(msgId, false); + }); + // Reply button propagates up to GxsCommentDialog via signal + connect(itemWidget, &CommentItemWidget::replyClicked, this, &FlatViewCommentWidget::commentReply); + + // Collapse/expand toggle for reply threads + connect(itemWidget, &CommentItemWidget::viewRepliesToggled, + this, &FlatViewCommentWidget::onViewRepliesToggled); + + // Click-to-select within the list + connect(itemWidget, &CommentItemWidget::commentSelected, + this, &FlatViewCommentWidget::onCommentSelected); + + // Async identity resolution fills name and avatar + GxsIdDetails::process(comment.mMeta.mAuthorId, fillCommentItemWidgetCallback, itemWidget); + + if (parentId.isNull()) { + mCommentsLayout->insertWidget(mCommentsLayout->count() - 1, itemWidget); + } else { + mRepliesMap[parentId].append(comment.mMeta.mMsgId); + CommentItemWidget *parentWidget = mCommentWidgets.value(parentId, nullptr); + if (parentWidget) { + int insertPos = mCommentsLayout->indexOf(parentWidget) + 1; + // Skip past any replies already placed after this parent + while (insertPos < mCommentsLayout->count() - 1) { + CommentItemWidget *ciw = qobject_cast(mCommentsLayout->itemAt(insertPos)->widget()); + if (ciw && ciw->getLevel() > 0) + ++insertPos; + else + break; + } + mCommentsLayout->insertWidget(insertPos, itemWidget); + } else { + mCommentsLayout->insertWidget(mCommentsLayout->count() - 1, itemWidget); + } + } +} + +void FlatViewCommentWidget::loadCommentsForPost(const RsGxsGroupId &groupId, const std::set &msgVersions, const RsGxsMessageId &mostRecentMsgId) +{ + mCurrentGroupId = groupId; + mCurrentPostId = mostRecentMsgId; + mLatestMsgId = mostRecentMsgId; + mMsgVersions = msgVersions; + + clearComments(); + + if (!mCommentService) { + qDebug() << "FlatViewCommentWidget: No comment service set"; + return; + } + + RsThread::async([this, groupId, msgVersions]() + { + std::vector comments; + + if (!mCommentService->getRelatedComments(groupId, msgVersions, comments)) { + std::cerr << "FlatViewCommentWidget: failed to get comments" << std::endl; + return; + } + + RsQThreadUtils::postToObject([this, comments, msgVersions]() + { + clearComments(); + + // First pass: top-level comments — parent is one of the post's own message IDs + for (const auto &comment : comments) { + if (msgVersions.count(comment.mMeta.mParentId)) { + addComment(comment, RsGxsMessageId()); + } + } + + // Second pass: replies — parent is another comment, not the post itself + for (const auto &comment : comments) { + if (!msgVersions.count(comment.mMeta.mParentId)) { + addComment(comment, comment.mMeta.mParentId); + } + } + + // Stamp each top-level comment with its reply count + updateReplyCountButtons(); + }, this); + }); +} + +void FlatViewCommentWidget::sortComments(int sortMethod) +{ + // Collect top-level CommentItemWidget pointers + QList topLevel; + for (auto it = mCommentWidgets.begin(); it != mCommentWidgets.end(); ++it) { + if (it.value()->getLevel() == 0) + topLevel.append(it.value()); + } + + if (topLevel.isEmpty()) + return; + + std::sort(topLevel.begin(), topLevel.end(), [&](CommentItemWidget *a, CommentItemWidget *b) { + const RsGxsMessageId aId = a->getMsgId(); + const RsGxsMessageId bId = b->getMsgId(); + if (sortMethod == 1) // New: timestamp descending + return mTimestampMap.value(aId, 0) > mTimestampMap.value(bId, 0); + if (sortMethod == 2) // Top: score ascending + return mScoreMap.value(aId, 0.0) < mScoreMap.value(bId, 0.0); + // Hot (0, default): score descending + return mScoreMap.value(aId, 0.0) > mScoreMap.value(bId, 0.0); + }); + + // Detach all comment widgets from the layout without deleting them; + // delete the QLayoutItem* wrapper (required by Qt), not the widget itself. + while (mCommentsLayout->count() > 1) + delete mCommentsLayout->takeAt(0); + + // Re-insert: each top-level widget followed immediately by its replies + for (CommentItemWidget *parent : topLevel) { + mCommentsLayout->insertWidget(mCommentsLayout->count() - 1, parent); + for (const RsGxsMessageId &replyId : mRepliesMap.value(parent->getMsgId())) { + CommentItemWidget *reply = mCommentWidgets.value(replyId, nullptr); + if (reply) + mCommentsLayout->insertWidget(mCommentsLayout->count() - 1, reply); + } + } +} + +void FlatViewCommentWidget::doVote(const RsGxsMessageId &commentMsgId, bool up) +{ + if (!mCommentService) { + qDebug() << "FlatViewCommentWidget::doVote: no comment service"; + return; + } + if (mVoterId.isNull()) { + QMessageBox::warning(this, tr("Cannot vote"), tr("Please select an identity to vote with.")); + return; + } + + RsGxsGroupId groupId = mCurrentGroupId; + RsGxsMessageId threadId = mLatestMsgId; + RsGxsId voterId = mVoterId; + std::set versions = mMsgVersions; + + RsThread::async([this, groupId, threadId, commentMsgId, voterId, up, versions]() + { + std::string error_string; + RsGxsMessageId vote_id; + RsGxsVoteType tvote = up ? RsGxsVoteType::UP : RsGxsVoteType::DOWN; + + bool res = mCommentService->voteForComment(groupId, threadId, commentMsgId, voterId, tvote, vote_id, error_string); + + RsQThreadUtils::postToObject([this, res, error_string, groupId, versions, threadId]() + { + if (res) + loadCommentsForPost(groupId, versions, threadId); + else + QMessageBox::critical(nullptr, tr("Cannot vote"), + tr("Error while voting: ") + QString::fromStdString(error_string)); + }, this); + }); +} + +void FlatViewCommentWidget::onViewRepliesToggled(const RsGxsMessageId &msgId, bool show) +{ + for (const RsGxsMessageId &replyId : mRepliesMap.value(msgId)) { + CommentItemWidget *reply = mCommentWidgets.value(replyId, nullptr); + if (reply) + reply->setVisible(show); + } +} + +void FlatViewCommentWidget::onCommentSelected(const RsGxsMessageId &msgId) +{ + if (mSelectedWidget) { + mSelectedWidget->setSelected(false); + mSelectedWidget = nullptr; + } + CommentItemWidget *w = mCommentWidgets.value(msgId, nullptr); + if (w) { + w->setSelected(true); + mSelectedWidget = w; + } +} + +CommentItemWidget *FlatViewCommentWidget::getCommentWidget(const RsGxsMessageId &msgId) const +{ + return mCommentWidgets.value(msgId, nullptr); +} diff --git a/retroshare-gui/src/gui/gxs/FlatViewCommentWidget.h b/retroshare-gui/src/gui/gxs/FlatViewCommentWidget.h new file mode 100644 index 000000000..94652b97e --- /dev/null +++ b/retroshare-gui/src/gui/gxs/FlatViewCommentWidget.h @@ -0,0 +1,90 @@ +/******************************************************************************* + * retroshare-gui/src/gui/gxs/FlatViewCommentWidget.h * + * * + * Copyright 206 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 FLAT_VIEW_COMMENT_WIDGET_H +#define FLAT_VIEW_COMMENT_WIDGET_H + +#include +#include +#include +#include + +#include + +#include + +namespace Ui { +class FlatViewCommentWidget; +} + +class CommentItemWidget; + +class FlatViewCommentWidget : public QWidget +{ + Q_OBJECT + +public: + explicit FlatViewCommentWidget(QWidget *parent = nullptr); + ~FlatViewCommentWidget(); + + // Add a comment at top level or as a reply to a parent + void addComment(const RsGxsComment &comment, const RsGxsMessageId &parentId = RsGxsMessageId()); + void clearComments(); + + // Set the comment service for loading comments + void setCommentService(RsGxsCommentService *service); + void setVoterId(const RsGxsId &id); + void updateReplyCountButtons(); + CommentItemWidget *getCommentWidget(const RsGxsMessageId &msgId) const; + + signals: + void commentUpvote(const RsGxsGrpMsgIdPair &msgId, bool up); + void commentDownvote(const RsGxsGrpMsgIdPair &msgId, bool down); + void commentReply(const RsGxsMessageId &parentId); + +public slots: + void loadCommentsForPost(const RsGxsGroupId &groupId, const std::set &msgVersions, const RsGxsMessageId &mostRecentMsgId); + void sortComments(int sortMethod); + +private slots: + void onViewRepliesToggled(const RsGxsMessageId &msgId, bool show); + void onCommentSelected(const RsGxsMessageId &msgId); + +private: + void doVote(const RsGxsMessageId &commentMsgId, bool up); + + Ui::FlatViewCommentWidget *ui; + + QVBoxLayout *mCommentsLayout; + QMap mCommentWidgets; + QMap > mRepliesMap; + QMap mScoreMap; + QMap mTimestampMap; + + RsGxsCommentService *mCommentService; + RsGxsGroupId mCurrentGroupId; + RsGxsMessageId mCurrentPostId; + RsGxsMessageId mLatestMsgId; + std::set mMsgVersions; + RsGxsId mVoterId; + CommentItemWidget *mSelectedWidget; +}; + +#endif // FLAT_VIEW_COMMENT_WIDGET_H diff --git a/retroshare-gui/src/gui/gxs/FlatViewCommentWidget.ui b/retroshare-gui/src/gui/gxs/FlatViewCommentWidget.ui new file mode 100644 index 000000000..bfb7a0351 --- /dev/null +++ b/retroshare-gui/src/gui/gxs/FlatViewCommentWidget.ui @@ -0,0 +1,85 @@ + + + FlatViewCommentWidget + + + + 0 + 0 + 600 + 400 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + true + + + Qt::ScrollBarAsNeeded + + + + + 0 + 0 + 598 + 398 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Vertical + + + + 20 + 20 + + + + + + + + + + + + + CommentItemWidget + QWidget +
gui/gxs/CommentItemWidget.h
+ 1 +
+
+ + +
diff --git a/retroshare-gui/src/gui/gxs/GxsCommentDialog.cpp b/retroshare-gui/src/gui/gxs/GxsCommentDialog.cpp index 568c73492..fd1d7c71c 100644 --- a/retroshare-gui/src/gui/gxs/GxsCommentDialog.cpp +++ b/retroshare-gui/src/gui/gxs/GxsCommentDialog.cpp @@ -20,6 +20,9 @@ #include "gui/gxs/GxsCommentDialog.h" #include "gui/gxs/GxsCommentTreeWidget.h" +#include "gui/gxs/FlatViewCommentWidget.h" +#include "gui/gxs/CommentItemWidget.h" +#include "gui/gxs/GxsCreateCommentDialog.h" #include "ui_GxsCommentDialog.h" #include @@ -28,12 +31,14 @@ #include #include #include +#include +#include //#define DEBUG_COMMENT_DIALOG 1 /** Constructor */ GxsCommentDialog::GxsCommentDialog(QWidget *parent, const RsGxsId &default_author, RsGxsCommentService *comment_service) - : QWidget(parent), ui(new Ui::GxsCommentDialog) + : QWidget(parent), ui(new Ui::GxsCommentDialog), mUseFlatView(false), mFlatViewWidget(nullptr), mCommentService(nullptr) { /* Invoke the Qt Designer generated QObject setup routine */ ui->setupUi(this); @@ -60,7 +65,9 @@ void GxsCommentDialog::init(const RsGxsId& default_author) connect(ui->commentButton, SIGNAL(clicked()), ui->treeWidget, SLOT(makeComment())); connect(ui->sortBox, SIGNAL(currentIndexChanged(int)), this, SLOT(sortComments(int))); - + + connect(ui->viewModeButton, &QToolButton::toggled, this, &GxsCommentDialog::onFlatViewToggled); + // default sort method "HOT". ui->treeWidget->sortByColumn(4, Qt::DescendingOrder); @@ -71,11 +78,12 @@ void GxsCommentDialog::init(const RsGxsId& default_author) void GxsCommentDialog::setGxsService(RsGxsCommentService *comment_service) { + mCommentService = comment_service; ui->treeWidget->setup(comment_service); } GxsCommentDialog::GxsCommentDialog(QWidget *parent,const RsGxsId &default_author) - : QWidget(parent), ui(new Ui::GxsCommentDialog) + : QWidget(parent), ui(new Ui::GxsCommentDialog), mUseFlatView(false), mFlatViewWidget(nullptr), mCommentService(nullptr) { /* Invoke the Qt Designer generated QObject setup routine */ ui->setupUi(this); @@ -91,6 +99,9 @@ GxsCommentDialog::~GxsCommentDialog() void GxsCommentDialog::commentClear() { ui->treeWidget->clear(); + if (mFlatViewWidget) { + mFlatViewWidget->clearComments(); + } mGrpId.clear(); mMostRecentMsgId.clear(); mMsgVersions.clear(); @@ -109,6 +120,13 @@ void GxsCommentDialog::commentLoad(const RsGxsGroupId &grpId, const std::settreeWidget->setUseCache(use_cache); ui->treeWidget->requestComments(mGrpId,msg_versions,most_recent_msgId); + + if (mUseFlatView) { + setupFlatViewWidget(); + if (mFlatViewWidget) { + mFlatViewWidget->loadCommentsForPost(mGrpId, mMsgVersions, mMostRecentMsgId); + } + } } void GxsCommentDialog::notifyCommentsLoaded(int n) @@ -131,6 +149,7 @@ void GxsCommentDialog::idChooserReady() void GxsCommentDialog::voterSelectionChanged( int index ) { + Q_UNUSED(index) #ifdef DEBUG_COMMENT_DIALOG std::cerr << "GxsCommentDialog::voterSelectionChanged(" << index << ")"; std::cerr << std::endl; @@ -145,6 +164,8 @@ void GxsCommentDialog::voterSelectionChanged( int index ) std::cerr << std::endl; #endif ui->treeWidget->setVoteId(voterId); + if (mFlatViewWidget) + mFlatViewWidget->setVoterId(voterId); break; case GxsIdChooser::NoId: @@ -198,19 +219,131 @@ void GxsCommentDialog::setCommentHeader(QWidget *header) void GxsCommentDialog::sortComments(int i) { - switch(i) { default: case 0: - ui->treeWidget->sortByColumn(4, Qt::DescendingOrder); + if (mUseFlatView && mFlatViewWidget) { + mFlatViewWidget->sortComments(0); + } else { + ui->treeWidget->sortByColumn(4, Qt::DescendingOrder); + } break; case 1: - ui->treeWidget->sortByColumn(2, Qt::DescendingOrder); + if (mUseFlatView && mFlatViewWidget) { + mFlatViewWidget->sortComments(1); + } else { + ui->treeWidget->sortByColumn(2, Qt::DescendingOrder); + } break; case 2: - ui->treeWidget->sortByColumn(3, Qt::DescendingOrder); + if (mUseFlatView && mFlatViewWidget) { + mFlatViewWidget->sortComments(2); + } else { + ui->treeWidget->sortByColumn(3, Qt::DescendingOrder); + } break; } - +} + +void GxsCommentDialog::setupFlatViewWidget() +{ + if (mFlatViewWidget) + return; + + mFlatViewWidget = new FlatViewCommentWidget(ui->flatViewPage); + mFlatViewWidget->setCommentService(mCommentService); + + connect(mFlatViewWidget, &FlatViewCommentWidget::commentReply, + this, &GxsCommentDialog::onFlatViewCommentReply); + + // Propagate the already-chosen voter ID if one was selected before the widget was created + { + RsGxsId voterId; + GxsIdChooser::ChosenId_Ret ret = ui->idChooser->getChosenId(voterId); + if (ret == GxsIdChooser::KnowId || ret == GxsIdChooser::UnKnowId) + mFlatViewWidget->setVoterId(voterId); + } + + QVBoxLayout *pageLayout = new QVBoxLayout(ui->flatViewPage); + pageLayout->setContentsMargins(0, 0, 0, 0); + pageLayout->addWidget(mFlatViewWidget); +} + +void GxsCommentDialog::loadFlatViewComments(const std::vector &comments) +{ + if (!mFlatViewWidget) + setupFlatViewWidget(); + + mUseFlatView = true; + ui->viewModeButton->setChecked(true); + ui->commentStackedWidget->setCurrentWidget(ui->flatViewPage); + + mFlatViewWidget->clearComments(); + + // Collect all comment msgIds to distinguish top-level (parent not a comment) from replies + std::set commentIds; + for (const auto &c : comments) + commentIds.insert(c.mMeta.mMsgId); + + // First pass: top-level comments + for (const auto &comment : comments) { + if (!commentIds.count(comment.mMeta.mParentId)) + mFlatViewWidget->addComment(comment, RsGxsMessageId()); + } + // Second pass: replies + for (const auto &comment : comments) { + if (commentIds.count(comment.mMeta.mParentId)) + mFlatViewWidget->addComment(comment, comment.mMeta.mParentId); + } + + mFlatViewWidget->updateReplyCountButtons(); +} + +void GxsCommentDialog::loadFlatView() +{ + mUseFlatView = true; + ui->viewModeButton->setChecked(true); + setupFlatViewWidget(); + ui->commentStackedWidget->setCurrentWidget(ui->flatViewPage); + + if (mFlatViewWidget) + mFlatViewWidget->loadCommentsForPost(mGrpId, mMsgVersions, mMostRecentMsgId); +} + +void GxsCommentDialog::onFlatViewToggled(bool checked) +{ + if (checked) { + mUseFlatView = true; + setupFlatViewWidget(); + ui->commentStackedWidget->setCurrentWidget(ui->flatViewPage); + if (mFlatViewWidget) + mFlatViewWidget->loadCommentsForPost(mGrpId, mMsgVersions, mMostRecentMsgId); + } else { + mUseFlatView = false; + ui->commentStackedWidget->setCurrentWidget(ui->classicPage); + } +} + +void GxsCommentDialog::onFlatViewCommentReply(const RsGxsMessageId &parentId) +{ + RsGxsId voterId; + ui->idChooser->getChosenId(voterId); + + GxsCreateCommentDialog dlg(mCommentService, + RsGxsGrpMsgIdPair(mGrpId, parentId), + mMostRecentMsgId, voterId, this); + + if (mFlatViewWidget) { + CommentItemWidget *parentWidget = mFlatViewWidget->getCommentWidget(parentId); + if (parentWidget) { + dlg.loadComment(parentWidget->getCommentText(), parentWidget->getAuthorName(), parentWidget->getAuthorId()); + } + } + + dlg.exec(); + + // Reload so the new reply appears + if (mFlatViewWidget) + mFlatViewWidget->loadCommentsForPost(mGrpId, mMsgVersions, mMostRecentMsgId); } diff --git a/retroshare-gui/src/gui/gxs/GxsCommentDialog.h b/retroshare-gui/src/gui/gxs/GxsCommentDialog.h index 6a3eee16c..7ac6f76fa 100644 --- a/retroshare-gui/src/gui/gxs/GxsCommentDialog.h +++ b/retroshare-gui/src/gui/gxs/GxsCommentDialog.h @@ -23,6 +23,8 @@ #include "gui/gxs/GxsCommentContainer.h" +class FlatViewCommentWidget; + namespace Ui { class GxsCommentDialog; } @@ -44,20 +46,28 @@ public: RsGxsGroupId groupId() { return mGrpId; } RsGxsMessageId messageId() { return mMostRecentMsgId; } + // Flat view methods + void setUseFlatView(bool useFlatView) { mUseFlatView = useFlatView; } + void loadFlatViewComments(const std::vector &comments); + public slots: void refresh(); + void loadFlatView(); private slots: void idChooserReady(); void voterSelectionChanged( int index ); void sortComments(int); void notifyCommentsLoaded(int n); + void onFlatViewToggled(bool checked); + void onFlatViewCommentReply(const RsGxsMessageId &parentId); signals: void commentsLoaded(int); private: void init(const RsGxsId &default_author); + void setupFlatViewWidget(); RsGxsGroupId mGrpId; RsGxsMessageId mMostRecentMsgId; @@ -65,6 +75,10 @@ private: /* UI - from Designer */ Ui::GxsCommentDialog *ui; + + bool mUseFlatView; + FlatViewCommentWidget *mFlatViewWidget; + RsGxsCommentService *mCommentService; }; #endif diff --git a/retroshare-gui/src/gui/gxs/GxsCommentDialog.ui b/retroshare-gui/src/gui/gxs/GxsCommentDialog.ui index 7b50494d6..b250b0d6a 100644 --- a/retroshare-gui/src/gui/gxs/GxsCommentDialog.ui +++ b/retroshare-gui/src/gui/gxs/GxsCommentDialog.ui @@ -20,54 +20,6 @@ Form - - - - - 0 - 0 - - - - true - - - - Comment - - - - - Author - - - - - Date - - - - - Score - - - - - UpVotes - - - - - DownVotes - - - - - OwnVote - - - - @@ -145,6 +97,33 @@ + + + + <html><head/><body><p>Threaded view / flat view</p></body></html> + + + + :/icons/svg/threaded-view.svg + :/icons/svg/flat-view.svg:/icons/svg/threaded-view.svg + + + + 22 + 22 + + + + true + + + false + + + Qt::ToolButtonIconOnly + + + @@ -177,6 +156,81 @@ + + + + + 0 + 0 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + true + + + + Comment + + + + + Author + + + + + Date + + + + + Score + + + + + UpVotes + + + + + DownVotes + + + + + OwnVote + + + + + + + + + diff --git a/retroshare-gui/src/retroshare-gui.pro b/retroshare-gui/src/retroshare-gui.pro index cdb9cef3d..a7c81df05 100644 --- a/retroshare-gui/src/retroshare-gui.pro +++ b/retroshare-gui/src/retroshare-gui.pro @@ -1452,6 +1452,8 @@ gxsgui { gui/gxs/GxsCommentTreeWidget.h \ gui/gxs/GxsCommentContainer.h \ gui/gxs/GxsCommentDialog.h \ + gui/gxs/CommentItemWidget.h \ + gui/gxs/FlatViewCommentWidget.h \ gui/gxs/GxsCreateCommentDialog.h \ gui/gxs/GxsGroupFrameDialog.h \ gui/gxs/GxsMessageFrameWidget.h \ @@ -1467,6 +1469,8 @@ gxsgui { FORMS += gui/gxs/GxsGroupDialog.ui \ gui/gxs/GxsCommentContainer.ui \ gui/gxs/GxsCommentDialog.ui \ + gui/gxs/CommentItemWidget.ui \ + gui/gxs/FlatViewCommentWidget.ui \ gui/gxs/GxsCreateCommentDialog.ui \ gui/gxs/GxsGroupFrameDialog.ui\ gui/gxs/GxsGroupShareKey.ui @@ -1484,6 +1488,8 @@ gxsgui { gui/gxs/GxsCommentTreeWidget.cpp \ gui/gxs/GxsCommentContainer.cpp \ gui/gxs/GxsCommentDialog.cpp \ + gui/gxs/CommentItemWidget.cpp \ + gui/gxs/FlatViewCommentWidget.cpp \ gui/gxs/GxsCreateCommentDialog.cpp \ gui/gxs/GxsGroupFrameDialog.cpp \ gui/gxs/GxsMessageFrameWidget.cpp \ From 594cb1fa082e3e6ba0b796236cfa19376a13a7b2 Mon Sep 17 00:00:00 2001 From: defnax <9952056+defnax@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:18:03 +0200 Subject: [PATCH 2/3] Update CMakeLists.txt --- retroshare-gui/src/CMakeLists.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/retroshare-gui/src/CMakeLists.txt b/retroshare-gui/src/CMakeLists.txt index d2025ae9c..f049e19f8 100644 --- a/retroshare-gui/src/CMakeLists.txt +++ b/retroshare-gui/src/CMakeLists.txt @@ -1115,6 +1115,8 @@ if(RS_GXSGUI) src/gui/gxs/GxsCommentTreeWidget.h src/gui/gxs/GxsCommentContainer.h src/gui/gxs/GxsCommentDialog.h + src/gui/gxs/CommentItemWidget.h + src/gui/gxs/FlatViewCommentWidget.h src/gui/gxs/GxsCreateCommentDialog.h src/gui/gxs/GxsGroupFrameDialog.h src/gui/gxs/GxsMessageFrameWidget.h @@ -1134,6 +1136,8 @@ if(RS_GXSGUI) src/gui/gxs/GxsGroupDialog.ui src/gui/gxs/GxsCommentContainer.ui src/gui/gxs/GxsCommentDialog.ui + src/gui/gxs/CommentItemWidget.ui + src/gui/gxs/FlatViewCommentWidget.ui src/gui/gxs/GxsCreateCommentDialog.ui src/gui/gxs/GxsGroupFrameDialog.ui src/gui/gxs/GxsGroupShareKey.ui @@ -1152,6 +1156,8 @@ if(RS_GXSGUI) src/gui/gxs/GxsCommentTreeWidget.cpp src/gui/gxs/GxsCommentContainer.cpp src/gui/gxs/GxsCommentDialog.cpp + src/gui/gxs/CommentItemWidget.cpp + src/gui/gxs/FlatViewCommentWidget.cpp src/gui/gxs/GxsCreateCommentDialog.cpp src/gui/gxs/GxsGroupFrameDialog.cpp src/gui/gxs/GxsMessageFrameWidget.cpp From 754c0a90f244ec6d6173c50c62390cca24cd461f Mon Sep 17 00:00:00 2001 From: defnax <9952056+defnax@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:42:49 +0200 Subject: [PATCH 3/3] fixed to use font metrix --- retroshare-gui/src/gui/gxs/GxsCommentDialog.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/retroshare-gui/src/gui/gxs/GxsCommentDialog.cpp b/retroshare-gui/src/gui/gxs/GxsCommentDialog.cpp index fd1d7c71c..12707bb09 100644 --- a/retroshare-gui/src/gui/gxs/GxsCommentDialog.cpp +++ b/retroshare-gui/src/gui/gxs/GxsCommentDialog.cpp @@ -74,6 +74,8 @@ void GxsCommentDialog::init(const RsGxsId& default_author) int S = QFontMetricsF(font()).height() ; ui->sortBox->setIconSize(QSize(S*1.5,S*1.5)); + ui->commentButton->setIconSize(QSize(S*1.5,S*1.5)); + ui->viewModeButton->setIconSize(QSize(S*1.5,S*1.5)); } void GxsCommentDialog::setGxsService(RsGxsCommentService *comment_service)