diff --git a/src/attica/atticaprovider.cpp b/src/attica/atticaprovider.cpp --- a/src/attica/atticaprovider.cpp +++ b/src/attica/atticaprovider.cpp @@ -55,6 +55,7 @@ SLOT(authenticationCredentialsMissing(Provider))); connect(this, &Provider::loadComments, this, &AtticaProvider::loadComments); connect(this, &Provider::loadPerson, this, &AtticaProvider::loadPerson); + connect(this, &Provider::postComment, this, &AtticaProvider::postComment); } AtticaProvider::AtticaProvider(const Attica::Provider &provider, const QStringList &categories, const QString& additionalAgentInformation) @@ -423,6 +424,22 @@ emit personLoaded(author); } +void AtticaProvider::postComment(const EntryInternal &entry, const QString& title, const QString& text, const QString& replyTo) +{ + ItemPostJob* job = m_provider.addNewComment(Attica::Comment::ContentComment, entry.uniqueId(), QStringLiteral(""), replyTo, title, text); + connect(job, &BaseJob::finished, this, &AtticaProvider::postedComment); + job->start(); +} + +void AtticaProvider::postedComment(Attica::BaseJob *baseJob) +{ + if (!jobSuccess(baseJob)) { + return; + } + + emit commentPosted(baseJob->metadata().statusCode()); +} + void AtticaProvider::accountBalanceLoaded(Attica::BaseJob *baseJob) { if (!jobSuccess(baseJob)) { @@ -484,6 +501,10 @@ return entries; } +bool AtticaProvider::userCanVote() { + return m_provider.hasCredentials(); +} + void AtticaProvider::vote(const EntryInternal &entry, uint rating) { PostJob *job = m_provider.voteForContent(entry.uniqueId(), rating); diff --git a/src/attica/atticaprovider_p.h b/src/attica/atticaprovider_p.h --- a/src/attica/atticaprovider_p.h +++ b/src/attica/atticaprovider_p.h @@ -75,11 +75,13 @@ * @see Provider::loadPerson(const QString &username) */ Q_SLOT void loadPerson(const QString &username); + /** + * The slot which causes the Attica provider to attempt to post a comment + * @see Provider::postComment(const EntryInternal &entry, const QString& title, const QString& text, const QString& replyTo); + */ + Q_SLOT void postComment(const EntryInternal &entry, const QString& title, const QString& text, const QString& replyTo); - bool userCanVote() override - { - return true; - } + bool userCanVote() override; void vote(const EntryInternal &entry, uint rating) override; bool userCanBecomeFan() override @@ -100,6 +102,7 @@ void detailsLoaded(Attica::BaseJob *job); void loadedComments(Attica::BaseJob *job); void loadedPerson(Attica::BaseJob *job); + void postedComment(Attica::BaseJob *job); private: void checkForUpdates(); diff --git a/src/core/commentsmodel.h b/src/core/commentsmodel.h --- a/src/core/commentsmodel.h +++ b/src/core/commentsmodel.h @@ -93,6 +93,33 @@ void setEntry(const KNSCore::EntryInternal &newEntry); Q_SIGNAL void entryChanged(); + /** + * @brief Add a new comment to an entry + * + * Creates a comment with a title and text on the entry with the requested ID + * + * @param title the title of the comment + * @param text the main text of the comment + * @param id the ID of the parent comment, if this comment is a reply + */ + Q_INVOKABLE void createComment(QString title, QString text, QString id); + + /** + * @brief Convenience function to send a comment and a vote immediately after each other + * + * Setting score on an entry immediately following creating a comment will be perceived + * as marking that comment as a review, and set the score of the review accordingly. + * This function is a convenienct way of ensuring this will definitely happen in the + * correct order and with the expected timing. + * + * @note there is no way to call this with a parent comment, since reviews should always be a top level comment + * + * @param title The title of the review + * @param text The main text of the review + * @param rating The rating you wish to set for the review (0 means remove this user's vote, 1-100 is the score the user wishes to give) + * @see KNSCore::CommmentsModel::createComment(QString title, QString text) + */ + Q_INVOKABLE void createReview(QString title, QString text, int rating); private: class Private; Private *d; diff --git a/src/core/commentsmodel.cpp b/src/core/commentsmodel.cpp --- a/src/core/commentsmodel.cpp +++ b/src/core/commentsmodel.cpp @@ -223,3 +223,27 @@ d->fetch(Private::ClearModel); emit entryChanged(); } + +void KNSCore::CommentsModel::createComment(QString title, QString text, QString id) +{ + if (d->engine && d->entry.isValid() && d->engine->userCanVote(d->entry)) { + QSharedPointer provider = d->engine->provider(d->entry.providerId()); + provider->postComment(d->entry, title, text, id); + } +} + +void KNSCore::CommentsModel::createReview(QString title, QString text, int rating) +{ + if (d->engine && d->entry.isValid() && d->engine->userCanVote(d->entry)) { + QSharedPointer provider = d->engine->provider(d->entry.providerId()); + QMetaObject::Connection * const connection = new QMetaObject::Connection; + *connection = connect(provider.data(), &Provider::commentPosted, [this, connection, provider, rating](int status){ + QObject::disconnect(*connection); + if (status == 100) { + provider->vote(d->entry, rating); + } + delete connection; + }); + provider->postComment(d->entry, title, text, QStringLiteral("")); + } +} diff --git a/src/core/provider.h b/src/core/provider.h --- a/src/core/provider.h +++ b/src/core/provider.h @@ -171,6 +171,21 @@ * @since 5.63 */ Q_SIGNAL void loadPerson(const QString &username); + /** + * Request posting of a comment on an entry, optionally as a reply to another comment + * The engine listens to the commentPosted() signal for the result + * + * @note Implementation detail: All subclasses should connect to this signal + * and point it at a slot which does the actual work, if they support comments. + * + * TODO: KF6 This should be a virtual function, but can't do it now because BIC + * @param entry The entry to comment on + * @param title The title of the comment + * @param text The main text of the comment + * @param replyTo Some identifier for a comment this is intended as a reply to. If this is an empty string, consider it not a reply + * @since 5.67 + */ + Q_SIGNAL void postComment(const EntryInternal &entry, const QString& title, const QString& text, const QString& replyTo); virtual bool userCanVote() { @@ -239,6 +254,16 @@ * @since 5.63 */ void personLoaded(const std::shared_ptr author); + /** + * Fired when an attempt to post a comment has completed. The result of the attempt can + * be gleaned from the status parameter, and should be interpreted as follows: + * 100 - successful + * 101 - content must not be empty + * 102 - message or subject must not be empty + * 103 - no permission to add a comment + * @param status Whether or not the comment was posted successfully (see above for possible values) + */ + void commentPosted(int status); void signalInformation(const QString &) const; void signalError(const QString &) const; diff --git a/src/qtquick/qml/EntryDetails.qml b/src/qtquick/qml/EntryDetails.qml --- a/src/qtquick/qml/EntryDetails.qml +++ b/src/qtquick/qml/EntryDetails.qml @@ -55,6 +55,7 @@ property int downloadCount property var downloadLinks property string providerId + property bool userCanVote NewStuff.DownloadItemsSheet { id: downloadItemsSheet @@ -209,6 +210,7 @@ entryName: component.name entryAuthorId: component.author.name entryProviderId: component.providerId + userCanVote: component.userCanVote } } } diff --git a/src/qtquick/qml/private/EntryCommentDelegate.qml b/src/qtquick/qml/private/EntryCommentDelegate.qml --- a/src/qtquick/qml/private/EntryCommentDelegate.qml +++ b/src/qtquick/qml/private/EntryCommentDelegate.qml @@ -69,6 +69,19 @@ * The depth of the comment (in essence, how many parents the comment has) */ property int depth + /** + * Whether or not the user is able to do voting + */ + property bool userCanVote + /** + * Whether or not a user is able to create comments + * TODO KF6: Add the commenting capability to KNSCore::Provider, which can't be done easily now due to BIC issues + */ + property bool userCanComment: userCanVote + /** + * Fired when the user clicks on the reply control + */ + signal replyRequested(); spacing: 0 @@ -117,7 +130,6 @@ } QtLayouts.RowLayout { - visible: (component.title !== "" || component.score !== 0) QtLayouts.Layout.fillWidth: true QtLayouts.Layout.leftMargin: Kirigami.Units.largeSpacing Kirigami.Heading { @@ -128,8 +140,13 @@ } Rating { id: ratingStars + visible: rating > 0 rating: Math.floor(component.score / 10) } + Kirigami.LinkButton { + text: i18nc("The title for a control which allows the user to reply to a specific comment", "Reply") + onClicked: component.replyRequested() + } Item { QtLayouts.Layout.minimumWidth: Kirigami.Units.largeSpacing QtLayouts.Layout.maximumWidth: Kirigami.Units.largeSpacing diff --git a/src/qtquick/qml/private/EntryCommentsPage.qml b/src/qtquick/qml/private/EntryCommentsPage.qml --- a/src/qtquick/qml/private/EntryCommentsPage.qml +++ b/src/qtquick/qml/private/EntryCommentsPage.qml @@ -36,10 +36,16 @@ property string entryName property string entryAuthorId property string entryProviderId + property bool userCanVote property alias entryIndex: commentsModel.entryIndex property alias itemsModel: commentsModel.itemsModel title: i18nc("Title for the page containing a view of the comments for the entry", "Comments and Reviews for %1", component.entryName) actions { + main: Kirigami.Action { + text: i18nc("An action which show a sheet which allows the user to create a new comment", "Create Comment...") + icon.name: "comment-symbolic" + onTriggered: newCommentSheet.newComment("") + } contextualActions: [ Kirigami.Action { text: i18nc("Title for the item which is checked when all comments should be shown", "Show All Comments") @@ -83,6 +89,95 @@ title: model.subject reviewText: model.text depth: model.depth + userCanVote: component.userCanVote + onReplyRequested: newCommentSheet.newComment(model.id) + } + } + Kirigami.OverlaySheet { + id: newCommentSheet + function newComment(replyTo) { + if (replyTo == "") { + newCommentSheet.isReply = false; + newCommentSheet.replyTo = ""; + } else { + newCommentSheet.isReply = true; + newCommentSheet.replyTo = replyTo; + } + starRating.rating = 0; + newCommentSheet.open(); + // Explicitly not clearing the existing comment until it gets sent off + // (to avoid accidental deletion if the user closes the sheet for any reason) + } + showCloseButton: true + property bool isReply: false + property string replyTo: "" + header: QtLayouts.ColumnLayout { + spacing: Kirigami.Units.largeSpacing + Kirigami.Heading { + QtLayouts.Layout.fillWidth: true + text: newCommentSheet.isReply ? i18nc("The title for a dialog which lets you write a reply to some comment", "Reply To Comment") : i18nc("The title for a dialog which lets you enter a new comment or review", "Create New Comment") + elide: Text.ElideRight + } + QtControls.Label { + QtLayouts.Layout.fillWidth: true + QtLayouts.Layout.margins: Kirigami.Units.largeSpacing + text: i18n("Enter the title and text of your comment in the fields below. If you wish for this comment to be a review, also select a rating by selecting a star rating from one through five.") + wrapMode: Text.Wrap + } + } + QtLayouts.ColumnLayout { + QtLayouts.Layout.preferredWidth: commentsView.width - Kirigami.Units.largeSpacing * 4 + Kirigami.FormLayout { + wideMode: false + QtLayouts.Layout.fillWidth: true + Kirigami.ActionTextField { + id: newCommentTitle + QtLayouts.Layout.fillWidth: true + Kirigami.FormData.label: i18nc("Label for a text field for entering a subject or title line for a comment", "Title:") + rightActions: [ + Kirigami.Action { + iconName: "edit-clear" + visible: newCommentTitle.text !== "" + onTriggered: newCommentTitle.text = "" + } + ] + } + Rating { + id: starRating + visible: !newCommentSheet.isReply + QtLayouts.Layout.fillWidth: true + editable: true + Kirigami.FormData.label: i18nc("Label for a star rating selector for a review", "Rating:") + } + } + QtControls.TextArea { + id: newCommentText + placeholderText: i18nc("A placeholder text for the text area into which the user should type their comment", "Type in your comment here") + QtLayouts.Layout.fillWidth: true + QtLayouts.Layout.minimumHeight: newCommentTitle.height * 4 + } + QtLayouts.RowLayout { + Item { + QtLayouts.Layout.fillWidth: true + height: Kirigami.Units.largeSpacing + } + QtControls.Button { + text: starRating.rating > 0 ? i18nc("Label for a button which will post a new review", "Post Review") : i18nc("Label for a button which will post a new comment", "Post Comment") + enabled: newCommentTitle.text.length > 0 && newCommentText.text.length > 0 + onClicked: { + if (starRating.rating > 0) { + // Then this is a review + } else { + // Otherwise it's a comment + } + // once submitted, close and clear + newCommentSheet.close(); + newCommentTitle.text = ""; + newCommentText.text = ""; + starRating.rating = 0; + } + } + } } } } diff --git a/src/qtquick/qml/private/Rating.qml b/src/qtquick/qml/private/Rating.qml --- a/src/qtquick/qml/private/Rating.qml +++ b/src/qtquick/qml/private/Rating.qml @@ -19,6 +19,7 @@ import QtQuick 2.11 import QtQuick.Layouts 1.11 +import QtQuick.Controls 2.11 import org.kde.kirigami 2.0 as Kirigami @@ -63,4 +64,10 @@ } } } + ToolButton { + icon.name: "edit-clear" + onClicked: view.rating = 0 + enabled: view.editable && view.rating > 0 + visible: view.editable; + } } diff --git a/src/qtquick/qml/private/entrygriddelegates/BigPreviewDelegate.qml b/src/qtquick/qml/private/entrygriddelegates/BigPreviewDelegate.qml --- a/src/qtquick/qml/private/entrygriddelegates/BigPreviewDelegate.qml +++ b/src/qtquick/qml/private/entrygriddelegates/BigPreviewDelegate.qml @@ -49,7 +49,8 @@ rating: model.rating, downloadCount: model.downloadCount, downloadLinks: model.downloadLinks, - providerId: model.providerId + providerId: model.providerId, + userCanVote: model.userCanVote }); } actions: [ diff --git a/src/qtquick/qml/private/entrygriddelegates/ThumbDelegate.qml b/src/qtquick/qml/private/entrygriddelegates/ThumbDelegate.qml --- a/src/qtquick/qml/private/entrygriddelegates/ThumbDelegate.qml +++ b/src/qtquick/qml/private/entrygriddelegates/ThumbDelegate.qml @@ -140,7 +140,8 @@ rating: model.rating, downloadCount: model.downloadCount, downloadLinks: model.downloadLinks, - providerId: model.providerId + providerId: model.providerId, + userCanVote: model.userCanVote }); } } diff --git a/src/qtquick/qml/private/entrygriddelegates/TileDelegate.qml b/src/qtquick/qml/private/entrygriddelegates/TileDelegate.qml --- a/src/qtquick/qml/private/entrygriddelegates/TileDelegate.qml +++ b/src/qtquick/qml/private/entrygriddelegates/TileDelegate.qml @@ -50,7 +50,8 @@ rating: model.rating, downloadCount: model.downloadCount, downloadLinks: model.downloadLinks, - providerId: model.providerId + providerId: model.providerId, + userCanVote: model.userCanVote }); } actions: [ diff --git a/src/qtquick/quickitemsmodel.h b/src/qtquick/quickitemsmodel.h --- a/src/qtquick/quickitemsmodel.h +++ b/src/qtquick/quickitemsmodel.h @@ -98,6 +98,7 @@ InstalledFilesRole, UnInstalledFilesRole, RatingRole, + UserCanVoteRole, NumberOfCommentsRole, DownloadCountRole, NumberFansRole, @@ -169,10 +170,21 @@ * * @note This will simply fail quietly if the item is not installed * - * @param index The intex of the item to be adopted + * @param index The index of the item to be adopted */ Q_INVOKABLE void adoptItem(int index); + /** + * @brief Vote for an item + * + * If the user is able, this will rate the item + * + * @param index The index of the item to vote for + * @param rating The rating you wish to set for the item (0 means remove this user's vote, 1-100 is the score the user wishes to give) + * @see KNSCore::CommentsModel::createReview( + */ + Q_INVOKABLE void voteForItem(int index, int rating); + /** * @brief Fired when an entry's data changes * diff --git a/src/qtquick/quickitemsmodel.cpp b/src/qtquick/quickitemsmodel.cpp --- a/src/qtquick/quickitemsmodel.cpp +++ b/src/qtquick/quickitemsmodel.cpp @@ -122,6 +122,7 @@ {InstalledFilesRole, "installedFiles"}, {UnInstalledFilesRole, "uninstalledFiles"}, {RatingRole, "rating"}, + {UserCanVoteRole, "userCanVote"}, ///<@ While it seems strange to hold this here when core has it on Engine, this information is conceptually per-Entry {NumberOfCommentsRole, "numberOfComments"}, {DownloadCountRole, "downloadCount"}, {NumberFansRole, "numberFans"}, @@ -243,6 +244,9 @@ case RatingRole: data.setValue(entry.rating()); break; + case UserCanVoteRole: + data.setValue(d->coreEngine->userCanVote(entry)); + break; case NumberOfCommentsRole: data.setValue(entry.numberOfComments()); break; @@ -429,3 +433,13 @@ } } } + +void ItemsModel::voteForItem(int index, int rating) +{ + if (d->coreEngine) { + KNSCore::EntryInternal entry = d->model->data(d->model->index(index), Qt::UserRole).value(); + if (d->coreEngine->userCanVote(entry)) { + d->coreEngine->vote(entry, rating); + } + } +} diff --git a/src/ui/entrydetailsdialog.cpp b/src/ui/entrydetailsdialog.cpp --- a/src/ui/entrydetailsdialog.cpp +++ b/src/ui/entrydetailsdialog.cpp @@ -127,8 +127,10 @@ // Most of the voting is 20 - 80, so rate 20 as 0 stars and 80 as 5 stars int rating = qMax(0, qMin(10, (m_entry.rating() - 20) / 6)); ui->ratingWidget->setRating(rating); - connect(ui->ratingWidget, static_cast(&KRatingWidget::ratingChanged), - this, &EntryDetails::ratingChanged); + if (m_engine->userCanVote(entry)) { + connect(ui->ratingWidget, static_cast(&KRatingWidget::ratingChanged), + this, &EntryDetails::ratingChanged); + } } else { ui->ratingWidget->setVisible(false); } diff --git a/src/ui/itemsviewdelegate.cpp b/src/ui/itemsviewdelegate.cpp --- a/src/ui/itemsviewdelegate.cpp +++ b/src/ui/itemsviewdelegate.cpp @@ -81,9 +81,7 @@ rating->setMaxRating(10); rating->setHalfStepsEnabled(true); list << rating; - const KNSCore::EntryInternal entry = index.data(Qt::UserRole).value(); - connect(rating, static_cast(&KRatingWidget::ratingChanged), - this, [this, entry](unsigned int newRating){m_engine->vote(entry, newRating * 10);}); + // Not supposed to be able to actually vote from the listview, so we're not connecting the widget to anything return list; }