Repository: frappe/frappe
Pull request: feat: publish comment from desk
Comments made from the portal or on a blog post get the _Published_ property. However, there was no way to create public comments from the desk, that would be visible to portal or website users. This PR introduces a menu option to change comment publicity.

https://github.com/user-attachments/assets/9968d7cf-868e-4f63-bbe5-28646fc6c3c9

Resolves #32257

Ref: ABI-13

> no-docs (can add after merge, if requested)

Problem statement:
Users comments are not appear in customer portal
There is no option to choose comment visibility for user and make it visible for customers in website view. By default comment doctype sets published property to 0. 

Would be cool to add published toggle or checkbox to easily share certain comments with customers.

Hints from issue discussion:

Commit message: fix: clarify confirmation message

Patch to review:
diff --git a/frappe/desk/form/load.py b/frappe/desk/form/load.py
index 83f1d5c0bf88..0005974417a7 100644
--- a/frappe/desk/form/load.py
+++ b/frappe/desk/form/load.py
@@ -149,7 +149,7 @@ def add_comments(doc, docinfo):
 
 	comments = frappe.get_all(
 		"Comment",
-		fields=["name", "creation", "content", "owner", "comment_type"],
+		fields=["name", "creation", "content", "owner", "comment_type", "published"],
 		filters={"reference_doctype": doc.doctype, "reference_name": doc.name},
 	)
 
diff --git a/frappe/desk/form/utils.py b/frappe/desk/form/utils.py
index 2d8406790d49..2dbd07081d16 100644
--- a/frappe/desk/form/utils.py
+++ b/frappe/desk/form/utils.py
@@ -68,6 +68,16 @@ def update_comment(name, content):
 	doc.save(ignore_permissions=True)
 
 
+@frappe.whitelist()
+def update_comment_publicity(name: str, publish: bool):
+	doc = frappe.get_doc("Comment", name)
+	if frappe.session.user != doc.owner and "System Manager" not in frappe.get_roles():
+		frappe.throw(_("Comment publicity can only be updated by the original author or a System Manager."))
+
+	doc.published = int(publish)
+	doc.save(ignore_permissions=True)
+
+
 @frappe.whitelist()
 def get_next(doctype, value, prev, filters=None, sort_order="desc", sort_field="creation"):
 	prev = int(prev)
diff --git a/frappe/public/js/frappe/form/footer/form_timeline.js b/frappe/public/js/frappe/form/footer/form_timeline.js
index c79c17619f1a..68cec3eb78ec 100644
--- a/frappe/public/js/frappe/form/footer/form_timeline.js
+++ b/frappe/public/js/frappe/form/footer/form_timeline.js
@@ -608,18 +608,20 @@ class FormTimeline extends BaseTimeline {
 		let edit_box = this.make_editable(edit_wrapper);
 		let content_wrapper = comment_wrapper.find(".content");
 		let more_actions_wrapper = comment_wrapper.find(".more-actions");
-		if (
-			frappe.model.can_delete("Comment") &&
-			(frappe.session.user == doc.owner || frappe.user.has_role("System Manager"))
-		) {
-			const delete_option = $(`
-				<li>
-					<a class="dropdown-item">
-						${__("Delete")}
-					</a>
-				</li>
-			`).click(() => this.delete_comment(doc.name));
-			more_actions_wrapper.find(".dropdown-menu").append(delete_option);
+		const dropdown_menu = more_actions_wrapper.find(".dropdown-menu li");
+
+		if (frappe.session.user == doc.owner || frappe.user.has_role("System Manager")) {
+			if (frappe.model.can_delete("Comment")) {
+				const delete_option = $(`
+					<a class="dropdown-item">${__("Delete")}</a>
+				`).click(() => this.delete_comment(doc.name));
+				dropdown_menu.append(delete_option);
+			}
+
+			const un_publish_button = $(`
+				<a class="dropdown-item">${doc.published ? __("Unpublish") : __("Publish")}</a>
+			`).click(() => this.update_comment_publicity(doc.name, !doc.published));
+			dropdown_menu.append(un_publish_button);
 		}
 
 		let dismiss_button = $(`
@@ -730,6 +732,31 @@ class FormTimeline extends BaseTimeline {
 		});
 	}
 
+	update_comment_publicity(comment_name, publish) {
+		let message;
+		if (publish) {
+			message = __(
+				"Would you like to publish this comment? This means it will become visible to website/portal users."
+			);
+		} else {
+			message = __(
+				"Would you like to unpublish this comment? This means it will no longer be visible to website/portal users."
+			);
+		}
+
+		frappe.confirm(message, () => {
+			return frappe
+				.xcall("frappe.desk.form.utils.update_comment_publicity", {
+					name: comment_name,
+					publish,
+				})
+				.then(() => {
+					frappe.utils.play_sound("click");
+					this.frm.reload_doc();
+				});
+		});
+	}
+
 	copy_link(ev) {
 		let doc_link = frappe.urllib.get_full_url(
 			frappe.utils.get_form_link(this.frm.doctype, this.frm.docname)
diff --git a/frappe/public/js/frappe/form/templates/timeline_message_box.html b/frappe/public/js/frappe/form/templates/timeline_message_box.html
index 0242c00fdd20..d7db828e6467 100644
--- a/frappe/public/js/frappe/form/templates/timeline_message_box.html
+++ b/frappe/public/js/frappe/form/templates/timeline_message_box.html
@@ -35,6 +35,12 @@
 					<span class="text-extra-muted">
 						{{ comment_when(doc.communication_date || doc.creation) }}
 					</span>
+					{% if (doc.published) { %}
+						<span> · </span>
+						<span class="text-extra-muted" title="{{ __('Visible to website/portal users.') }}">
+							{{ __("Published") }}
+						</span>
+					{% } %}
 				</span>
 			{% } else { %}
 				<span class="margin-right">


Code review comments:
diff_hunk: @@ -730,6 +732,31 @@ class FormTimeline extends BaseTimeline {
 		});
 	}
 
+	update_comment_publicity(comment_name, publish) {
+		let message;
+		if (publish) {
+			message = __(
+				"Would you like to publish this comment? This means it will become visible to website/portal users."
+			);
+		} else {
+			message = __(
+				"Would you like to unpublish this comment? This means it will no longer be visible to website/portal users."
+			);
+		}
+
+		frappe.confirm(message, () => {
+			return frappe
+				.xcall("frappe.desk.form.utils.update_comment_publicity", {
+					name: comment_name,
+					publish,
+				})
+				.then(() => {
+					frappe.utils.play_sound("click");
+					this.frm.reload_doc();
path: frappe/public/js/frappe/form/footer/form_timeline.js
text: @user1:
- Reloading the document doesn't seem like the best approach here, but reloading the timeline does.
- Deleting a comment handles this similarly except it has a socket event listener that refreshes the timeline 
https://github.com/frappe/frappe/blob/6bbd85a37dcd5321df67f8f44bcb522e78c064aa/frappe/public/js/frappe/form/form.js#L2082-L2092

We could just handle this in our callback
```suggestion
					me.refresh();
```