Github Reply Comments

Easy reply to Github comments

// ==UserScript==
// @name             Github Reply Comments
// @namespace        https://github.com/jerone/UserScripts
// @description      Easy reply to Github comments
// @author           jerone
// @copyright        2016+, jerone (https://github.com/jerone)
// @license          CC-BY-NC-SA-4.0; https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode
// @license          GPL-3.0-or-later; http://www.gnu.org/licenses/gpl-3.0.txt
// @homepage         https://github.com/jerone/UserScripts/tree/master/Github_Reply_Comments
// @homepageURL      https://github.com/jerone/UserScripts/tree/master/Github_Reply_Comments
// @supportURL       https://github.com/jerone/UserScripts/issues
// @contributionURL  https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=VCYMHWQ7ZMBKW
// @version          1.0.6
// @icon             https://github.githubassets.com/pinned-octocat.svg
// @grant            none
// @include          https://github.com/*
// @include          https://gist.github.com/*
// @require          https://unpkg.com/turndown@5.0.3/dist/turndown.js
// @require          https://unpkg.com/turndown-plugin-gfm@1.0.2/dist/turndown-plugin-gfm.js
// @require          https://unpkg.com/turndown-plugin-github-code-snippet@1.0.2/turndown-plugin-github-code-snippet.js
// ==/UserScript==

// cSpell:ignore textareas, previewable, tooltipped
/* eslint security/detect-object-injection: "off" */
/* global TurndownService,turndownPluginGfm,turndownPluginGithubCodeSnippet */

(function () {
	String.format = function (string) {
		var args = Array.prototype.slice.call(arguments, 1, arguments.length);
		return string.replace(/{(\d+)}/g, function (match, number) {
			return typeof args[number] !== "undefined" ? args[number] : match;
		});
	};

	function turndownPluginGitHubAlert(turndownService) {
		turndownService.addRule("gfm-alert", {
			filter: function (node, _options) {
				return (
					node.nodeName === "DIV" &&
					node.classList.contains("markdown-alert")
				);
			},
			replacement: function (content, node, options) {
				const variant = node
					.querySelector(".markdown-alert-title")
					.innerText.trim();
				content = content.replace(/^\n+|\n+$/g, "");
				content = content.replace(
					// eslint-disable-next-line security/detect-non-literal-regexp
					new RegExp("^" + variant),
					"[!" + variant.toUpperCase() + "]",
				);
				return options.rules.blockquote.replacement(content);
			},
		});
	}

	var turndownService = new TurndownService({
		headingStyle: "atx",
		codeBlockStyle: "fenced",
		hr: "***",
	});
	turndownService.use(turndownPluginGfm.gfm);
	turndownService.use(turndownPluginGithubCodeSnippet);
	turndownService.use(turndownPluginGitHubAlert);

	function getCommentTextarea(replyBtn) {
		var newComment = replyBtn;
		while (
			newComment &&
			!newComment.classList.contains("js-quote-selection-container")
		) {
			newComment = newComment.parentNode;
		}

		var inlineComment = newComment.querySelector(
			".js-inline-comment-form-container",
		);
		if (inlineComment) {
			inlineComment.classList.add("open");
		}

		var textareas = newComment.querySelectorAll(
			":scope > :not(.last-review-thread) .js-comment-field:not(.github-writer-ckeditor)",
		);
		return textareas[textareas.length - 1];
	}

	function getCommentMarkdown(comment) {
		var commentText = "";

		// Use raw comment when available.
		// Extra scope is needed to get the correct comment field, which is not an "Reference new issue" modal (with org rights).
		var commentForm = comment.querySelector(
			":scope > .js-comment-update .js-comment-field",
		);
		if (commentForm) {
			commentText = commentForm.value;
		}

		// Convert comment HTML to markdown.
		if (!commentText) {
			// Clone it, so we can alter the HTML a bit, without modifying the page.
			var commentBody = comment
				.querySelector(".comment-body")
				.cloneNode(true);

			// Skip empty PR description.
			if (
				commentBody
					.querySelector("em")
					?.innerText.includes("No description provided.")
			) {
				return "";
			}

			// Remove 'Toggle code wrap' buttons from https://greasyfork.org/en/scripts/18789-github-toggle-code-wrap
			Array.prototype.forEach.call(
				commentBody.querySelectorAll(".ghd-wrap-toggle"),
				function (ghd) {
					ghd.remove();
				},
			);

			// Refined GitHub adds a small avatar to username mention. See https://github.com/refined-github/refined-github/blob/main/source/features/small-user-avatars.tsx
			Array.prototype.forEach.call(
				commentBody.querySelectorAll(".rgh-small-user-avatars"),
				function (rgh) {
					rgh.remove();
				},
			);

			// GitHub add an extra new line, which is converted by Turndown.
			Array.prototype.forEach.call(
				commentBody.querySelectorAll("pre code"),
				function (pre) {
					pre.innerHTML = pre.innerHTML.replace(/\n$/g, "");
				},
			);

			commentText = turndownService.turndown(commentBody.innerHTML);
		}

		return commentText;
	}

	function addReplyButtons() {
		Array.prototype.forEach.call(
			document.querySelectorAll(".comment, .review-comment"),
			function (comment) {
				var oldReply = comment.querySelector(
					".GithubReplyComments, .GithubCommentEnhancerReply",
				);
				if (oldReply) {
					oldReply.parentNode.removeChild(oldReply);
				}

				var header = comment.querySelector(
						":scope > :not(.minimized-comment) .timeline-comment-header",
					),
					actions = comment.querySelector(
						":scope > :not(.minimized-comment) .timeline-comment-actions",
					);

				if (!header) {
					header = actions;
				}

				if (!actions) {
					if (!header) {
						return;
					}
					actions = document.createElement("div");
					actions.classList.add("timeline-comment-actions");
					header.insertBefore(actions, header.firstElementChild);
				}

				var reply = document.createElement("button");
				reply.setAttribute("type", "button");
				reply.setAttribute("title", "Reply to this comment");
				reply.setAttribute("aria-label", "Reply to this comment");
				reply.classList.add(
					"GithubReplyComments",
					"btn-link",
					"timeline-comment-action",
					"tooltipped",
					"tooltipped-ne",
				);
				reply.addEventListener("click", function (e) {
					e.preventDefault();

					var timestamp = comment.querySelector(
						".js-timestamp, .timestamp",
					);

					var commentText = getCommentMarkdown(comment);
					commentText = commentText
						.trim()
						.split("\n")
						.map(function (line) {
							return "> " + line;
						})
						.join("\n");

					var newComment = getCommentTextarea(this);

					var author = comment.querySelector(".author");
					var authorLink =
						location.origin +
						(author.getAttribute("href") ||
							"/" + author.textContent);

					var text = newComment.value.length > 0 ? "\n" : "";
					text += String.format(
						'[**@{0}**]({1}) commented on [{2}]({3} "{4} - Replied by Github Reply Comments"):\n{5}\n\n',
						author.textContent,
						authorLink,
						timestamp.firstElementChild.getAttribute("title"),
						timestamp.href,
						timestamp.firstElementChild.getAttribute("datetime"),
						commentText,
					);

					newComment.value += text;
					newComment.setSelectionRange(
						newComment.value.length,
						newComment.value.length,
					);
					//newComment.closest('.previewable-comment-form').querySelector('.js-write-tab').click();
					newComment.focus();

					// This will enable the "Comment" button, when there was no comment text yet.
					newComment.dispatchEvent(
						new CustomEvent("change", {
							bubbles: true,
							cancelable: false,
						}),
					);

					// This will render GitHub Writer - https://github.com/ckeditor/github-writer
					// https://github.com/ckeditor/github-writer/blob/8dbc12cb01b7903d0d6c90202078214a8637de6d/src/app/plugins/quoteselection.js#L116-L127
					const githubWriter = newComment.closest(
						[
							"form.js-new-comment-form[data-github-writer-id]",
							"form.js-inline-comment-form[data-github-writer-id]",
						].join(),
					);
					if (githubWriter) {
						window.postMessage(
							{
								type: "GitHub-Writer-Quote-Selection",
								id: Number(
									githubWriter.getAttribute(
										"data-github-writer-id",
									),
								),
								text: text,
							},
							"*",
						);
					}
				});

				var svg = document.createElementNS(
					"http://www.w3.org/2000/svg",
					"svg",
				);
				svg.classList.add("octicon", "octicon-mail-reply");
				svg.setAttribute("height", "16");
				svg.setAttribute("width", "16");
				reply.appendChild(svg);
				var path = document.createElementNS(
					"http://www.w3.org/2000/svg",
					"path",
				);
				path.setAttribute(
					"d",
					"M6 2.5l-6 4.5 6 4.5v-3c1.73 0 5.14 0.95 6 4.38 0-4.55-3.06-7.05-6-7.38v-3z",
				);
				svg.appendChild(path);

				actions.appendChild(reply);
			},
		);
	}

	// init;
	addReplyButtons();

	// on pjax;
	document.addEventListener("pjax:end", addReplyButtons);
})();