FEATURE: Real-time updates for accepted and unaccepted solutions

Introduce real-time message bus updates for accepted and unaccepted solutions, ensuring live synchronization across users.

Key changes:
- Publish solution acceptance/unacceptance updates via MessageBus.
- Refactor `accepted_answer_post_info` and related logic for cleaner handling of accepted answer data.
- Update both backend and frontend to support reactive updates when solutions are toggled.
- Add loading states for Accept/Unaccept buttons to enhance UX during async operations.
This commit is contained in:
Sérgio Saquetim 2025-06-24 01:08:02 -03:00
parent 436ee57801
commit 5aed0b41c5
No known key found for this signature in database
GPG Key ID: B4E3D7F11E793062
8 changed files with 177 additions and 135 deletions

View File

@ -13,9 +13,9 @@ class DiscourseSolved::AnswerController < ::ApplicationController
guardian.ensure_can_accept_answer!(topic, post)
DiscourseSolved.accept_answer!(post, current_user, topic: topic)
accepted_answer = DiscourseSolved.accept_answer!(post, current_user, topic: topic)
render json: success_json
render_json_dump(accepted_answer)
end
def unaccept

View File

@ -1,4 +1,5 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";
@ -13,6 +14,8 @@ export default class SolvedAcceptAnswerButton extends Component {
@service appEvents;
@service currentUser;
@tracked saving = false;
get showLabel() {
return this.currentUser?.id === this.args.post.topicCreatedById;
}
@ -21,13 +24,17 @@ export default class SolvedAcceptAnswerButton extends Component {
acceptAnswer() {
const post = this.args.post;
acceptPost(post, this.currentUser);
this.saving = true;
try {
acceptPost(post, this.currentUser);
} finally {
this.saving = false;
}
this.appEvents.trigger("discourse-solved:solution-toggled", post);
// TODO (glimmer-post-stream) the Glimmer Post Stream does not listen to this event
post.get("topic.postStream.posts").forEach((p) => {
p.set("topic_accepted_answer", true);
// TODO (glimmer-post-stream) the Glimmer Post Stream does not listen to this event
this.appEvents.trigger("post-stream:refresh", { id: p.id });
});
}
@ -37,6 +44,7 @@ export default class SolvedAcceptAnswerButton extends Component {
class="post-action-menu__solved-unaccepted unaccepted"
...attributes
@action={{this.acceptAnswer}}
@disabled={{this.saving}}
@icon="far-square-check"
@label={{if this.showLabel "solved.solution"}}
@title="solved.accept_answer"
@ -44,42 +52,21 @@ export default class SolvedAcceptAnswerButton extends Component {
</template>
}
function acceptPost(post, acceptingUser) {
async function acceptPost(post) {
if (!post.can_accept_answer || post.accepted_answer) {
return;
}
const topic = post.topic;
clearAccepted(topic);
try {
const acceptedAnswer = await ajax("/solution/accept", {
type: "POST",
data: { id: post.id },
});
post.setProperties({
can_unaccept_answer: true,
can_accept_answer: false,
accepted_answer: true,
});
topic.set("accepted_answer", {
username: post.username,
name: post.name,
post_number: post.post_number,
excerpt: post.cooked,
accepter_username: acceptingUser.username,
accepter_name: acceptingUser.name,
});
ajax("/solution/accept", {
type: "POST",
data: { id: post.id },
}).catch(popupAjaxError);
}
function clearAccepted(topic) {
const posts = topic.get("postStream.posts");
posts.forEach((post) => {
if (post.get("post_number") > 1) {
post.setProperties({
accepted_answer: false,
can_accept_answer: true,
can_unaccept_answer: false,
topic_accepted_answer: false,
});
}
});
topic.setAcceptedSolution(acceptedAnswer);
} catch (e) {
popupAjaxError(e);
}
}

View File

@ -1,7 +1,9 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import { and, not } from "truth-helpers";
import DButton from "discourse/components/d-button";
import icon from "discourse/helpers/d-icon";
import { ajax } from "discourse/lib/ajax";
@ -10,44 +12,11 @@ import { formatUsername } from "discourse/lib/utilities";
import { i18n } from "discourse-i18n";
import DTooltip from "float-kit/components/d-tooltip";
function unacceptPost(post) {
if (!post.can_unaccept_answer) {
return;
}
const topic = post.topic;
post.setProperties({
can_accept_answer: true,
can_unaccept_answer: false,
accepted_answer: false,
});
topic.accepted_answer = undefined;
ajax("/solution/unaccept", {
type: "POST",
data: { id: post.id },
}).catch(popupAjaxError);
}
export default class SolvedUnacceptAnswerButton extends Component {
@service appEvents;
@service siteSettings;
@action
unacceptAnswer() {
const post = this.args.post;
unacceptPost(post);
this.appEvents.trigger("discourse-solved:solution-toggled", post);
post.get("topic.postStream.posts").forEach((p) => {
p.set("topic_accepted_answer", false);
// TODO (glimmer-post-stream) the Glimmer Post Stream does not listen to this event
this.appEvents.trigger("post-stream:refresh", { id: p.id });
});
}
@tracked saving = false;
get solvedBy() {
if (!this.siteSettings.show_who_marked_solved) {
@ -68,9 +37,28 @@ export default class SolvedUnacceptAnswerButton extends Component {
}
}
@action
async unacceptAnswer() {
const post = this.args.post;
this.saving = true;
try {
await unacceptPost(post);
} finally {
this.saving = false;
}
this.appEvents.trigger("discourse-solved:solution-toggled", post);
// TODO (glimmer-post-stream) the Glimmer Post Stream does not listen to this event
post.get("topic.postStream.posts").forEach((p) => {
this.appEvents.trigger("post-stream:refresh", { id: p.id });
});
}
<template>
<span class="extra-buttons">
{{#if @post.can_unaccept_answer}}
{{#if (and @post.can_accept_answer @post.accepted_answer)}}
{{#if this.solvedBy}}
<DTooltip @identifier="post-action-menu__solved-accepted-tooltip">
<:trigger>
@ -92,6 +80,7 @@ export default class SolvedUnacceptAnswerButton extends Component {
class="post-action-menu__solved-accepted accepted fade-out"
...attributes
@action={{this.unacceptAnswer}}
@disabled={{this.saving}}
@icon="square-check"
@label="solved.solution"
@title="solved.unaccept_answer"
@ -111,3 +100,22 @@ export default class SolvedUnacceptAnswerButton extends Component {
</span>
</template>
}
async function unacceptPost(post) {
if (!post.can_accept_answer || !post.accepted_answer) {
return;
}
const topic = post.topic;
try {
await ajax("/solution/unaccept", {
type: "POST",
data: { id: post.id },
});
topic.setAcceptedSolution(undefined);
} catch (e) {
popupAjaxError(e);
}
}

View File

@ -11,19 +11,11 @@ import SolvedUnacceptAnswerButton from "../components/solved-unaccept-answer-but
function initializeWithApi(api) {
customizePost(api);
customizePostMenu(api);
handleMessages(api);
if (api.addDiscoveryQueryParam) {
api.addDiscoveryQueryParam("solved", { replace: true, refreshModel: true });
}
}
function customizePost(api) {
api.addTrackedPostProperties(
"can_accept_answer",
"can_unaccept_answer",
"accepted_answer",
"topic_accepted_answer"
);
api.modifyClass(
"model:topic",
@ -31,8 +23,41 @@ function customizePost(api) {
class extends Superclass {
@tracked accepted_answer;
@tracked has_accepted_answer;
setAcceptedSolution(acceptedAnswer) {
this.postStream?.posts?.forEach((post) => {
if (!acceptedAnswer) {
post.setProperties({
accepted_answer: false,
topic_accepted_answer: false,
});
} else if (post.post_number > 1) {
post.setProperties(
acceptedAnswer.post_number === post.post_number
? {
accepted_answer: true,
topic_accepted_answer: true,
}
: {
accepted_answer: false,
topic_accepted_answer: true,
}
);
}
});
this.accepted_answer = acceptedAnswer;
}
}
);
}
function customizePost(api) {
api.addTrackedPostProperties(
"can_accept_answer",
"accepted_answer",
"topic_accepted_answer"
);
api.renderAfterWrapperOutlet(
"post-content-cooked-html",
@ -84,10 +109,10 @@ function customizePostMenu(api) {
}) => {
let solvedButton;
if (post.can_accept_answer) {
solvedButton = SolvedAcceptAnswerButton;
} else if (post.accepted_answer) {
if (post.accepted_answer) {
solvedButton = SolvedUnacceptAnswerButton;
} else if (post.can_accept_answer) {
solvedButton = SolvedAcceptAnswerButton;
}
solvedButton &&
@ -110,19 +135,32 @@ function customizePostMenu(api) {
);
}
function handleMessages(api) {
const handleMessages = async (controller, message) => {
const topic = controller.model;
if (topic) {
topic.setAcceptedSolution(message.accepted_answer);
}
};
api.registerCustomPostMessageCallback("accepted_solution", handleMessages);
api.registerCustomPostMessageCallback("unaccepted_solution", handleMessages);
}
export default {
name: "extend-for-solved-button",
initialize() {
withPluginApi("1.34.0", initializeWithApi);
withPluginApi(initializeWithApi);
withPluginApi("0.8.10", (api) => {
withPluginApi((api) => {
api.replaceIcon(
"notification.solved.accepted_notification",
"square-check"
);
});
withPluginApi("0.11.0", (api) => {
withPluginApi((api) => {
api.addAdvancedSearchOptions({
statusOptions: [
{
@ -137,7 +175,7 @@ export default {
});
});
withPluginApi("0.11.7", (api) => {
withPluginApi((api) => {
api.addSearchSuggestion("status:solved");
api.addSearchSuggestion("status:unsolved");
});

View File

@ -21,7 +21,9 @@ module DiscourseSolved
def can_accept_answer?(topic, post)
return false if !authenticated?
return false if !topic || topic.private_message? || !post || post.whisper?
if !topic || topic.private_message? || !post || post.post_number <= 1 || post.whisper?
return false
end
return false if !allow_accepted_answers?(topic.category_id, topic.tags.map(&:name))
return true if is_staff?

View File

@ -4,4 +4,43 @@ module DiscourseSolved::TopicExtension
extend ActiveSupport::Concern
prepended { has_one :solved, class_name: "DiscourseSolved::SolvedTopic", dependent: :destroy }
def accepted_answer_post_info
return nil unless solved
answer_post = solved.answer_post
answer_post_user = answer_post.user
accepter = solved.accepter
excerpt =
if SiteSetting.solved_quote_length > 0
PrettyText.excerpt(
answer_post.cooked,
SiteSetting.solved_quote_length,
keep_emoji_images: true,
)
else
nil
end
accepted_answer = {
post_number: answer_post.post_number,
username: answer_post_user.username,
name: answer_post_user.name,
excerpt:,
}
if SiteSetting.show_who_marked_solved
accepted_answer[:accepter_name] = accepter.name
accepted_answer[:accepter_username] = accepter.username
end
if !SiteSetting.enable_names || !SiteSetting.display_name_on_posts
accepted_answer[:name] = nil
accepted_answer[:accepter_name] = nil
end
accepted_answer
end
end

View File

@ -11,45 +11,6 @@ module DiscourseSolved::TopicViewSerializerExtension
end
def accepted_answer
accepted_answer_post_info
end
private
def accepted_answer_post_info
solved = object.topic.solved
answer_post = solved.answer_post
answer_post_user = answer_post.user
accepter = solved.accepter
excerpt =
if SiteSetting.solved_quote_length > 0
PrettyText.excerpt(
answer_post.cooked,
SiteSetting.solved_quote_length,
keep_emoji_images: true,
)
else
nil
end
accepted_answer = {
post_number: answer_post.post_number,
username: answer_post_user.username,
name: answer_post_user.name,
excerpt:,
}
if SiteSetting.show_who_marked_solved
accepted_answer[:accepter_name] = accepter.name
accepted_answer[:accepter_username] = accepter.username
end
if !SiteSetting.enable_names || !SiteSetting.display_name_on_posts
accepted_answer[:name] = nil
accepted_answer[:accepter_name] = nil
end
accepted_answer
object.topic.accepted_answer_post_info
end
end

View File

@ -112,7 +112,14 @@ after_initialize do
WebHook.enqueue_solved_hooks(:accepted_solution, post, payload)
end
accepted_answer = topic.reload.accepted_answer_post_info
message = { type: :accepted_solution, accepted_answer: }
DiscourseEvent.trigger(:accepted_solution, post)
MessageBus.publish("/topic/#{topic.id}", message)
accepted_answer
end
end
@ -140,7 +147,9 @@ after_initialize do
payload = WebHook.generate_payload(:post, post)
WebHook.enqueue_solved_hooks(:unaccepted_solution, post, payload)
end
DiscourseEvent.trigger(:unaccepted_solution, post)
MessageBus.publish("/topic/#{topic.id}", type: :unaccepted_solution)
end
end
@ -252,9 +261,7 @@ after_initialize do
.count
end
add_to_serializer(:user_summary, :solved_count) { object.solved_count }
add_to_serializer(:post, :can_accept_answer) do
scope.can_accept_answer?(topic, object) && object.post_number > 1 && !accepted_answer
end
add_to_serializer(:post, :can_accept_answer) { scope.can_accept_answer?(topic, object) }
add_to_serializer(:post, :can_unaccept_answer) do
scope.can_accept_answer?(topic, object) && accepted_answer
end