From ec76a37e9f6834af66a43dc068ee39ce215895b1 Mon Sep 17 00:00:00 2001 From: Nat Date: Thu, 26 Jun 2025 17:29:32 +0800 Subject: [PATCH] FIX: Use SolvedTopics to list posts in /activity/solved instead of user actions --- .../solved_topics_controller.rb | 34 ++++++ .../solved_post_serializer.rb | 87 +++++++++++++ .../discourse/routes/user-activity-solved.js | 114 +++++++++++++++++- .../templates/user-activity-solved.gjs | 19 +++ config/routes.rb | 2 + spec/system/solved_spec.rb | 13 +- 6 files changed, 262 insertions(+), 7 deletions(-) create mode 100644 app/controllers/discourse_solved/solved_topics_controller.rb create mode 100644 app/serializers/discourse_solved/solved_post_serializer.rb create mode 100644 assets/javascripts/discourse/templates/user-activity-solved.gjs diff --git a/app/controllers/discourse_solved/solved_topics_controller.rb b/app/controllers/discourse_solved/solved_topics_controller.rb new file mode 100644 index 0000000..e488a57 --- /dev/null +++ b/app/controllers/discourse_solved/solved_topics_controller.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class DiscourseSolved::SolvedTopicsController < ::ApplicationController + requires_plugin DiscourseSolved::PLUGIN_NAME + + def by_user + params.permit(:username) + user = + fetch_user_from_params( + include_inactive: + current_user.try(:staff?) || (current_user && SiteSetting.show_inactive_accounts), + ) + raise Discourse::NotFound unless guardian.public_can_see_profiles? + raise Discourse::NotFound unless guardian.can_see_profile?(user) + + offset = [0, params[:offset].to_i].max + limit = params.fetch(:limit, 30).to_i + + posts = + Post + .joins( + "INNER JOIN discourse_solved_solved_topics ON discourse_solved_solved_topics.answer_post_id = posts.id", + ) + .joins(:topic) + .where(user_id: user.id, deleted_at: nil) + .where(topics: { archetype: Archetype.default, deleted_at: nil }) + .includes(:user, topic: %i[category tags]) + .order("discourse_solved_solved_topics.created_at DESC") + .offset(offset) + .limit(limit) + + render_serialized(posts, DiscourseSolved::SolvedPostSerializer, root: "user_solved_posts") + end +end diff --git a/app/serializers/discourse_solved/solved_post_serializer.rb b/app/serializers/discourse_solved/solved_post_serializer.rb new file mode 100644 index 0000000..b19a7c9 --- /dev/null +++ b/app/serializers/discourse_solved/solved_post_serializer.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +class DiscourseSolved::SolvedPostSerializer < ApplicationSerializer + attributes :created_at, + :archived, + :avatar_template, + :category_id, + :closed, + :cooked, + :excerpt, + :name, + :post_id, + :post_number, + :post_type, + :raw, + :slug, + :topic_id, + :topic_title, + :truncated, + :url, + :user_id, + :username + + def archived + object.topic.archived + end + + def avatar_template + object.user&.avatar_template + end + + def category_id + object.topic.category_id + end + + def closed + object.topic.closed + end + + def excerpt + @excerpt ||= PrettyText.excerpt(cooked, 300, keep_emoji_images: true) + end + + def name + object.user&.name + end + + def include_name? + SiteSetting.enable_names? + end + + def post_id + object.id + end + + def slug + Slug.for(object.topic.title) + end + + def include_slug? + object.topic.title.present? + end + + def topic_title + object.topic.title + end + + def truncated + true + end + + def include_truncated? + cooked.length > 300 + end + + def url + "#{Discourse.base_url}#{object.url}" + end + + def user_id + object.user_id + end + + def username + object.user&.username + end +end diff --git a/assets/javascripts/discourse/routes/user-activity-solved.js b/assets/javascripts/discourse/routes/user-activity-solved.js index ef41d18..35ea3be 100644 --- a/assets/javascripts/discourse/routes/user-activity-solved.js +++ b/assets/javascripts/discourse/routes/user-activity-solved.js @@ -1,15 +1,119 @@ -import UserActivityStreamRoute from "discourse/routes/user-activity-stream"; +import { tracked } from "@glimmer/tracking"; +import EmberObject from "@ember/object"; +import { service } from "@ember/service"; +import { Promise } from "rsvp"; +import { ajax } from "discourse/lib/ajax"; +import DiscourseRoute from "discourse/routes/discourse"; import { i18n } from "discourse-i18n"; -export default class UserActivitySolved extends UserActivityStreamRoute { - userActionType = 15; - noContentHelpKey = "solved.no_solutions"; +class SolvedPostsStream extends EmberObject { + @tracked content = []; + @tracked loading = false; + @tracked loaded = false; + @tracked itemsLoaded = 0; + @tracked canLoadMore = true; + + constructor(args) { + super(args); + this.username = args.username; + this.siteCategories = args.siteCategories; + } + + get noContent() { + return this.loaded && this.content.length === 0; + } + + findItems() { + if (this.loading || !this.canLoadMore) { + return Promise.resolve(); + } + + this.set("loading", true); + + const limit = 20; + return ajax( + `/solution/by_user.json?username=${this.username}&offset=${this.itemsLoaded}&limit=${limit}` + ) + .then((result) => { + const userSolvedPosts = result.user_solved_posts || []; + + if (userSolvedPosts.length === 0) { + this.set("canLoadMore", false); + return; + } + + const posts = userSolvedPosts.map((p) => { + const post = EmberObject.create(p); + post.set("titleHtml", post.topic_title); + post.set("postUrl", post.url); + + if (post.category_id && this.siteCategories) { + post.set( + "category", + this.siteCategories.find((c) => c.id === post.category_id) + ); + } + return post; + }); + + // Add to existing content + if (this.content.pushObjects) { + this.content.pushObjects(posts); + } else { + this.content = this.content.concat(posts); + } + + this.set("itemsLoaded", this.itemsLoaded + userSolvedPosts.length); + + if (userSolvedPosts.length < limit) { + this.set("canLoadMore", false); + } + }) + .finally(() => { + this.setProperties({ + loaded: true, + loading: false, + }); + }); + } +} + +export default class UserActivitySolved extends DiscourseRoute { + @service site; + @service currentUser; + + model() { + const user = this.modelFor("user"); + + const stream = new SolvedPostsStream({ + username: user.username, + siteCategories: this.site.categories, + }); + + return stream.findItems().then(() => { + return { + stream, + emptyState: this.emptyState(), + }; + }); + } + + setupController(controller, model) { + controller.setProperties({ + model, + emptyState: this.emptyState(), + }); + } + + renderTemplate() { + this.render("user-activity-solved"); + } emptyState() { const user = this.modelFor("user"); let title, body; - if (this.isCurrentUser(user)) { + if (this.currentUser && user.id === this.currentUser.id) { title = i18n("solved.no_solved_topics_title"); body = i18n("solved.no_solved_topics_body"); } else { diff --git a/assets/javascripts/discourse/templates/user-activity-solved.gjs b/assets/javascripts/discourse/templates/user-activity-solved.gjs new file mode 100644 index 0000000..4c00f85 --- /dev/null +++ b/assets/javascripts/discourse/templates/user-activity-solved.gjs @@ -0,0 +1,19 @@ +import RouteTemplate from "ember-route-template"; +import UserStream from "discourse/components/user-stream"; + +export default RouteTemplate( + +); diff --git a/config/routes.rb b/config/routes.rb index 2bfd416..2569aa2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -3,6 +3,8 @@ DiscourseSolved::Engine.routes.draw do post "/accept" => "answer#accept" post "/unaccept" => "answer#unaccept" + + get "/by_user" => "solved_topics#by_user" end Discourse::Application.routes.draw { mount ::DiscourseSolved::Engine, at: "solution" } diff --git a/spec/system/solved_spec.rb b/spec/system/solved_spec.rb index dc17f7a..c1cf526 100644 --- a/spec/system/solved_spec.rb +++ b/spec/system/solved_spec.rb @@ -1,11 +1,11 @@ # frozen_string_literal: true -describe "About page", type: :system do +describe "Solved", type: :system do fab!(:admin) fab!(:solver) { Fabricate(:user) } fab!(:accepter) { Fabricate(:user) } fab!(:topic) { Fabricate(:post, user: admin).topic } - fab!(:post1) { Fabricate(:post, topic:, user: solver, cooked: "The answer is 42") } + fab!(:solver_post) { Fabricate(:post, topic:, user: solver, cooked: "The answer is 42") } let(:topic_page) { PageObjects::Pages::Topic.new } before do @@ -39,4 +39,13 @@ describe "About page", type: :system do end end end + + it "shows the solved post in user activity at /my/activity/solved" do + Fabricate(:solved_topic, topic:, answer_post: solver_post, accepter:) + + sign_in(solver) + visit "/my/activity/solved" + + expect(page.find(".post-list")).to have_content(solver_post.cooked) + end end