Stack Exchange comment template context menu

Adds a context menu (right click, long press, command click, etc) to comment boxes on Stack Exchange with customizable pre-written responses.

// ==UserScript==
// @name Stack Exchange comment template context menu
// @namespace http://ostermiller.org/
// @version 1.15
// @description Adds a context menu (right click, long press, command click, etc) to comment boxes on Stack Exchange with customizable pre-written responses.
// @match https://*.stackexchange.com/questions/*
// @match https://*.stackexchange.com/review/*
// @match https://*.stackexchange.com/admin/*
// @match https://*.stackoverflow.com/*questions/*
// @match https://*.stackoverflow.com/review/*
// @match https://*.stackoverflow.com/admin/*
// @match https://*.askubuntu.com/questions/*
// @match https://*.askubuntu.com/review/*
// @match https://*.askubuntu.com/admin/*
// @match https://*.superuser.com/questions/*
// @match https://*.superuser.com/review/*
// @match https://*.superuser.com/admin/*
// @match https://*.serverfault.com/questions/*
// @match https://*.serverfault.com/review/*
// @match https://*.serverfault.com/admin/*
// @match https://*.mathoverflow.net/questions/*
// @match https://*.mathoverflow.net/review/*
// @match https://*.mathoverflow.net/admin/*
// @match https://*.stackapps.com/questions/*
// @match https://*.stackapps.com/review/*
// @match https://*.stackapps.com/admin/*
// @connect raw.githubusercontent.com
// @connect *
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_xmlhttpRequest
// ==/UserScript==
(function() {
	'use strict'

	// Access to JavaScript variables from the Stack Exchange site
	var $ = unsafeWindow.jQuery

	// eg. physics.stackexchange.com -> physics
	function validateSite(s){
		var m = /^((?:meta\.)?[a-z0-9]+(?:\.meta)?)\.?[a-z0-9\.]*$/.exec(s.toLowerCase().trim().replace(/^(https?:\/\/)?(www\.)?/,""))
		if (!m) return null
		return m[1]
	}

	function validateTag(s){
		return s.toLowerCase().trim().replace(/ +/g,"-")
	}

	// eg hello-world, hello-worlds, hello world, hello worlds, and hw all map to hello-world
	function makeFilterMap(s){
		var m = {}
		s=s.split(/,/)
		for (var i=0; i<s.length; i++){
			// original
			m[s[i]] = s[i]
			// plural
			m[s[i]+"s"] = s[i]
			// with spaces
			m[s[i].replace(/-/g," ")] = s[i]
			// plural with spaces
			m[s[i].replace(/-/g," ")+"s"] = s[i]
			// abbreviation
			m[s[i].replace(/([a-z])[a-z]+(-|$)/g,"$1")] = s[i]
		}
		return m
	}

	var userMapInput = "moderator,user"
	var userMap = makeFilterMap(userMapInput)
	function validateUser(s){
		return userMap[s.toLowerCase().trim()]
	}

	var typeMapInput = "question,answer,edit-question,edit-answer,close-question,flag-comment,flag-question,flag-answer,decline-flag,helpful-flag,reject-edit"
	var typeMap = makeFilterMap(typeMapInput)
	typeMap.c = 'close-question'
	typeMap.close = 'close-question'

	function loadComments(urls){
		loadCommentsRecursive([], urls.split(/[\r\n ]+/))
	}

	function loadCommentsRecursive(aComments, aUrls){
		if (!aUrls.length) {
			if (aComments.length){
				comments = aComments
				storeComments()
				if(GM_getValue(storageKeys.url)){
					GM_setValue(storageKeys.lastUpdate, Date.now())
				}
			}
			return
		}
		var url = aUrls.pop()
		if (!url){
			loadCommentsRecursive(aComments, aUrls)
			return
		}
		console.log("Loading comments from " + url)
		GM_xmlhttpRequest({
			method: "GET",
			url: url,
			onload: function(r){
				var lComments = parseComments(r.responseText)
				if (!lComments || !lComments.length){
					alert("No comment templates loaded from " + url)
				} else {
					aComments = aComments.concat(lComments)
				}
				loadCommentsRecursive(aComments, aUrls)
			},
			onerror: function(){
				alert("Could not load comment templates from " + url)
				loadCommentsRecursive(aComments, aUrls)
			}
		})
	}

	function validateType(s){
		return typeMap[s.toLowerCase().trim()]
	}

	// Map of functions that clean up the filter-tags on comment templates
	var tagValidators = {
		tags: validateTag,
		sites: validateSite,
		users: validateUser,
		types: validateType
	}

	var attributeValidators = {
		socvr: trim
	}

	function trim(s){
		return s.trim()
	}

	// Given a filter tag name and an array of filter tag values,
	// clean up and canonicalize each of them
	// Put them into a hash set (map each to true) for performant lookups
	function validateAllTagValues(tag, arr){
		var ret = {}
		for (var i=0; i<arr.length; i++){
			// look up the validation function for the filter tag type and call it
			var v = tagValidators[tag](arr[i])
			// Put it in the hash set
			if (v) ret[v]=1
		}
		if (Object.keys(ret).length) return ret
		return null
	}

	function validateValues(tag, value){
			if (tag in tagValidators) return validateAllTagValues(tag, value.split(/,/))
			if (tag in attributeValidators) return attributeValidators[tag](value)
			return null
	}

	// List of keys used for storage, centralized for multiple usages
	var storageKeys = {
		comments: "ctcm-comments",
		url: "ctcm-url",
		lastUpdate: "ctcm-last-update"
	}

	// On-load, parse comment templates from local storage
	var comments = parseComments(GM_getValue(storageKeys.comments))
	// The download comment templates from URL if configured
	if(GM_getValue(storageKeys.url)){
		loadStorageUrlComments()
	} else if (!comments || !comments.length){
		// If there are NO comments, fetch the defaults
		loadComments("https://raw.githubusercontent.com/stephenostermiller/stack-exchange-comment-templates/master/default-templates.txt")
	}

	function hasCommentWarn(){
		return checkCommentLengths().length > 0
	}

	function commentWarnHtml(){
		var problems = checkCommentLengths()
		if (!problems.length) return $('<span>')
		var s = $("<ul>")
		for (var i=0; i<problems.length; i++){
			s.append($('<li>').text("⚠️ " + problems[i]))
		}
		return $('<div>').append($('<h3>').text("Problems")).append(s)
	}

	function checkCommentLengths(){
		var problems = []
		for (var i=0; i<comments.length; i++){
			var c = comments[i]
			var length = c.comment.length;
			if (length > 600){
				problems.push("Comment template is too long (" + length + "/600): " + c.title)
			} else if (length > 500 && (!c.types || c.types['flag-question'] || c.types['flag-answer'])){
				problems.push("Comment template is too long for flagging posts (" + length + "/500): " + c.title)
			} else if (length > 300 && (!c.types || c.types['edit-question'] || c.types['edit-answer'])){
				problems.push("Comment template is too long for an edit (" + length + "/300): " + c.title)
			} else if (length > 200 && (!c.types || c.types['decline-flag'] || c.types['helpful-flag'])){
				problems.push("Comment template is too long for flag handling (" + length + "/200): " + c.title)
			} else if (length > 200 && (!c.types || c.types['flag-comment'])){
				problems.push("Comment template is too long for flagging comments (" + length + "/200): " + c.title)
			}
		}
		return problems
	}

	// Serialize the comment templates into local storage
	function storeComments(){
		if (!comments || !comments.length) GM_deleteValue(storageKeys.comments)
		else GM_setValue(storageKeys.comments, exportComments())
	}

	function parseJsonpComments(s){
		var cs = []
		var callback = function(o){
			for (var i=0; i<o.length; i++){
				var c = {
					title: o[i].name,
					comment: o[i].description
				}
				var m = /^(?:\[([A-Z,]+)\])\s*(.*)$/.exec(c.title);
				if (m){
					c.title=m[2]
					c.types=validateValues("types",m[1])
				}
				if (c && c.title && c.comment) cs.push(c)
			}
		}
		eval(s)
		return cs
	}

	function parseComments(s){
		if (!s) return []
		if (s.startsWith("callback(")) return parseJsonpComments(s)
		var lines = s.split(/\n|\r|\r\n/)
		var c, m, cs = []
		for (var i=0; i<lines.length; i++){
			var line = lines[i].trim()
			if (!line){
				// Blank line indicates end of comment
				if (c && c.title && c.comment) cs.push(c)
				c=null
			} else {
				// Comment template title
				// Starts with #
				// May contain type filter tag abbreviations (for compat with SE-AutoReviewComments)
				// eg # Comment title
				// eg ### [Q,A] Comment title
				m = /^#+\s*(?:\[([A-Z,]+)\])?\s*(.*)$/.exec(line);
				if (m){
					// Stash previous comment if it wasn't already ended by a new line
					if (c && c.title && c.comment) cs.push(c)
					// Start a new comment with title
					c={title:m[2]}
					// Handle type filter tags if they exist
					if (m[1]) c.types=validateValues("types",m[1])
				} else if (c) {
					// Already started parsing a comment, look for filter tags and comment body
					m = /^(sites|types|users|tags|socvr)\:\s*(.*)$/.exec(line);
					if (m){
						// Add filter tags
						c[m[1]]=validateValues(m[1],m[2])
					} else {
						// Comment body (join multiple lines with spaces)
						if (c.comment) c.comment=c.comment+" "+line
						else c.comment=line
					}
				} else {
					// No comment started, didn't find a comment title
					console.log("Could not parse line from comment templates: " + line)
				}
			}
		}
		// Stash the last comment if it isn't followed by a new line
		if (c && c.title && c.comment) cs.push(c)
		return cs
	}

	function sort(arr){
		if (!(arr instanceof Array)) arr = Object.keys(arr)
		arr.sort()
		return arr
	}

	function exportComments(){
		var s ="";
		for (var i=0; i<comments.length; i++){
			var c = comments[i]
			s += "# " + c.title + "\n"
			s += c.comment + "\n"
			if (c.types) s += "types: " + sort(c.types).join(", ") + "\n"
			if (c.sites) s += "sites: " + sort(c.sites).join(", ") + "\n"
			if (c.users) s += "users: " + sort(c.users).join(", ") + "\n"
			if (c.tags) s += "tags: " + sort(c.tags).join(", ") + "\n"
			if (c.socvr) s += "socvr: " + c.socvr + "\n"
			s += "\n"
		}
		return s;
	}

	// inner lightbox content area
	var ctcmi = $('<div id=ctcm-menu>')
	// outer translucent lightbox background that covers the whole page
	var ctcmo = $('<div id=ctcm-back>').append(ctcmi)
	GM_addStyle("#ctcm-back{z-index:999998;display:none;position:fixed;left:0;top:0;width:100vw;height:100vh;background:rgba(0,0,0,.5)}")
	GM_addStyle("#ctcm-menu{z-index:999999;min-width:320px;position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);background:var(--white);border:5px solid var(--theme-header-foreground-color);padding:1em;max-width:100vw;max-height:100vh;overflow:auto}")
	GM_addStyle(".ctcm-body{display:none;background:var(--black-050);padding:.3em;cursor: pointer;")
	GM_addStyle(".ctcm-expand{float:right;cursor: pointer;}")
	GM_addStyle(".ctcm-title{margin-top:.3em;cursor: pointer;}")
	GM_addStyle("#ctcm-menu textarea{width:90vw;min-width:300px;max-width:1000px;height:60vh;resize:both;display:block}")
	GM_addStyle("#ctcm-menu input[type='text']{width:90vw;min-width:300px;max-width:1000px;display:block}")
	GM_addStyle("#ctcm-menu button{margin-top:1em;margin-right:.5em}")
	GM_addStyle("#ctcm-menu button.right{float:right}")
	GM_addStyle("#ctcm-menu h3{margin:.5em auto;font-size: 150%;}")

	// Node input: text field where content can be written.
	// Used for filter tags to know which comment templates to show in which contexts.
	// Also used for knowing which clicks should show the context menu,
	// if a type isn't returned by this method, no menu will show up
	function getType(node){
		var prefix = "";

		// Most of these rules use properties of the node or the node's parents
		// to deduce their context

		if (node.is('.js-rejection-reason-custom')) return "reject-edit"
		if (node.parents('.js-comment-flag-option').length) return "flag-comment"
		if (node.parents('.js-flagged-post').length){
			if (/decline/.exec(node.attr('placeholder'))) return "decline-flag"
			else return "helpful-flag"
		}

		if (node.parents('.site-specific-pane').length) prefix = "close-"
		else if (node.parents('.mod-attention-subform').length) prefix = "flag-"
		else if (node.is('.edit-comment,#edit-comment')) prefix = "edit-"
		else if(node.is('.js-comment-text-input')) prefix = ""
		else return null

		if (node.parents('#question,.question').length) return prefix + "question"
		if (node.parents('#answers,.answer').length) return prefix + "answer"

		// Fallback for single post edit page
		if (node.parents('.post-form').find('h2:last').text()=='Question') return prefix + "question"
		if (node.parents('.post-form').find('h2:last').text()=='Answer') return prefix + "answer"

		return null
	}

	// Mostly moderator or non-moderator (user.)
	// Not-logged in and low rep users are not able to comment much
	// and are unlikely to use this tool, no need to identify them
	// and give them special behavior.
	// Maybe add a class for staff in the future?
	var userclass
	function getUserClass(){
		if (!userclass){
			if ($('.js-mod-inbox-button').length) userclass="moderator"
			else if ($('.s-topbar--item.s-user-card').length) userclass="user"
			else userclass="anonymous"
		}
		return userclass
	}

	// The Stack Exchange site this is run on (just the subdomain, eg "stackoverflow")
	var site
	function getSite(){
		if(!site) site=validateSite(location.hostname)
		return site
	}

	// Which tags are on the question currently being viewed
	var tags
	function getTags(){
		if(!tags) tags=$.map($('.post-taglist .post-tag'),function(tag){return $(tag).text()})
		return tags
	}

	// The id of the question currently being viewed
	function getQuestionId(){
		var id = $('.question').attr('data-questionid')
		if (!id){
			var l = $('.answer-hyperlink')
			if (l.length) id=l.attr('href').replace(/^\/questions\/([0-9]+).*/,"$1")
		}
		if (!id) id="-"
		return id
	}

	// The human readable name of the current Stack Exchange site
	function getSiteName(){
		return $('meta[property="og:site_name"]').attr('content').replace(/ ?Stack Exchange/, "")
	}

	// The Stack Exchange user id for the person using this tool
	function getMyUserId() {
		return $('a.s-topbar--item.s-user-card').attr('href').replace(/^\/users\/([0-9]+)\/.*/,"$1")
	}

	// The Stack Exchange user name for the person using this tool
	function getMyName() {
		var n=$('header .s-avatar[title]').attr('title')
		if (!n) return "-"
		return n.replace(/ /g,"")
	}


	// The full host name of the Stack Exchange site
	function getSiteUrl(){
		return location.hostname
	}

	// Store the comment text field that was clicked on
	// so that it can be filled with the comment template
	var commentTextField

	// Insert the comment template into the text field
	// called when a template is clicked in the dialog box
	// so "this" refers to the clicked item
	function insertComment(){
		// The comment to insert is stored in a div
		// near the item that was clicked
		var body = $(this).parent().children('.ctcm-body')
		var socvr = body.attr('data-socvr')
		if (socvr){
			var url = "//" + getSiteUrl() + "/questions/" + getQuestionId()
			var title = $('h1').first().text()
			title = new Option(title).innerHTML
			$('#content').prepend($(`<div style="border:5px solid blue;padding:.7em;margin:.5em 0"><a target=_blank href=//chat.stackoverflow.com/rooms/41570/so-close-vote-reviewers>SOCVR: </a><div>[tag:cv-pls] ${socvr} [${title}](${url})</div></div>`))
		}
		var cmt = body.text()

		// Put in the comment
		commentTextField.val(cmt).focus()

		// highlight place for additional input,
		// if specified in the template
		var typeHere="[type here]"
		var typeHereInd = cmt.indexOf(typeHere)
		if (typeHereInd >= 0) commentTextField[0].setSelectionRange(typeHereInd, typeHereInd + typeHere.length)

		closeMenu()
	}

	// User clicked on the expand icon in the dialog
	// to show the full text of a comment
	function expandFullComment(){
		$(this).parent().children('.ctcm-body').show()
		$(this).hide()
	}

	// Apply comment tag filters
	// For a given comment, say whether it
	// should be shown given the current context
	function commentMatches(comment, type, user, site, tags){
		if (comment.types && !comment.types[type]) return false
		if (comment.users && !comment.users[user]) return false
		if (comment.sites && !comment.sites[site]) return false
		if (comment.tags){
			var hasTag = false
			for(var i=0; tags && i<tags.length; i++){
				if (comment.tags[tags[i]]) hasTag=true
			}
			if(!hasTag) return false
		}
		return true
	}

	// User clicked "Save" when editing the list of comment templates
	function doneEditing(){
		comments = parseComments($(this).prev('textarea').val())
		storeComments()
		closeMenu()
	}

	// Show the edit comment dialog
	function editComments(){
		// Pointless to edit comments that will just get overwritten
		// If there is a URL, only allow the URL to be edited
		if(GM_getValue(storageKeys.url)) return urlConf()
		ctcmi.html(
			"<pre># Comment title\n"+
			"Comment body\n"+
			"types: "+typeMapInput.replace(/,/g, ", ")+"\n"+
			"users: "+userMapInput.replace(/,/g, ", ")+"\n"+
			"sites: stackoverflow, physics, meta.stackoverflow, physics.meta, etc\n"+
			"tags: javascript, python, etc\n"+
			"socvr: Message for Stack Overflow close vote reviews chat</pre>"+
			"<p>types, users, sites, tags, and socvr are optional.</p>"
		)
		.append($('<textarea>').val(exportComments()))
		.append($('<button>Save</button>').click(doneEditing))
		.append($('<button>Cancel</button>').click(closeMenu))
		.append($('<button>From URL...</button>').click(urlConf))
		return false
	}

	// Show info
	function showInfo(){
		ctcmi.html(
			"<div><h2><a target=_blank href=//github.com/stephenostermiller/stack-exchange-comment-templates>Stack Exchange Comment Templates Context Menu</a></h2></div>"
		)
		.append(commentWarnHtml())
		.append(htmlVars())
		.append($('<button>Cancel</button>').click(closeMenu))
		return false
	}
	function getAuthorNode(postNode){
		return postNode.find('.post-signature .user-details[itemprop="author"]')
	}

	function getOpNode(){
		return getAuthorNode($('#question,.question'))
	}

	function getUserNodeId(node){
		if (!node) return "-"
		var link = node.find('a')
		if (!link.length) return "-"
		var href = link.attr('href')
		if (!href) return "-"
		return href.replace(/[^0-9]+/g, "")
	}

	function getOpId(){
		return getUserNodeId(getOpNode())
	}

	function getUserNodeName(node){
		if (!node) return "-"
		var link = node.find('a')
		if (!link.length) return "-"
		// Remove spaces from user names so that they can be used in @name references
		return link.text().replace(/ /g,"")
	}

	function getOpName(){
		return getUserNodeName(getOpNode())
	}

	function getUserNodeRep(node){
		if (!node) return "-"
		var r = node.find('.reputation-score')
		if (!r.length) return "-"
		return r.text()
	}

	function getOpRep(){
		return getUserNodeRep(getOpNode())
	}

	function getPostNode(){
		return commentTextField.parents('#question,.question,.answer')
	}

	function getPostAuthorNode(){
		return getAuthorNode(getPostNode())
	}

	function getAuthorId(){
		return getUserNodeId(getPostAuthorNode())
	}

	function getAuthorName(){
		return getUserNodeName(getPostAuthorNode())
	}

	function getAuthorRep(){
		return getUserNodeRep(getPostAuthorNode())
	}

	function getPostId(){
		var postNode = getPostNode();
		if (!postNode.length) return "-"
		if (postNode.attr('data-questionid')) return postNode.attr('data-questionid')
		if (postNode.attr('data-answerid')) return postNode.attr('data-answerid')
		return "-"
	}

	// Map of variables to functions that return their replacements
	var varMap = {
		'SITENAME': getSiteName,
		'SITEURL': getSiteUrl,
		'MYUSERID': getMyUserId,
		'MYNAME': getMyName,
		'QUESTIONID': getQuestionId,
		'OPID': getOpId,
		'OPNAME': getOpName,
		'OPREP': getOpRep,
		'POSTID': getPostId,
		'AUTHORID': getAuthorId,
		'AUTHORNAME': getAuthorName,
		'AUTHORREP': getAuthorRep
	}

	// Cache variables so they don't have to be looked up for every single question
	var varCache={}

	function getCachedVar(key){
		if (!varCache[key]) varCache[key] = varMap[key]()
		return varCache[key]
	}

	function hasVarWarn(){
		var varnames = Object.keys(varMap)
		for (var i=0; i<varnames.length; i++){
			if (getCachedVar(varnames[i]).match(/^-?$/)) return true
		}
		return false
	}

	function htmlVars(){
		var n = $("<ul>")
		var varnames = Object.keys(varMap)
		for (var i=0; i<varnames.length; i++){
			var li=$("<li>")
			var val = getCachedVar(varnames[i])
			if (val.match(/^-?$/)) li.append($("<span>").text("⚠️ "))
			li.append($("<b>").text(varnames[i])).append($("<span>").text(": ")).append($("<span>").text(val))
			n.append(li)
		}
		return $('<div>').append($('<h3>').text("Variables")).append(n)
	}

	// Build regex to find variables from keys of map
	var varRegex = new RegExp('\\$('+Object.keys(varMap).join('|')+')\\$?', 'g')
	function fillVariables(s){
		// Perform the variable replacement
		return s.replace(varRegex, function (m) {
			// Remove $ from variable name
			return getCachedVar(m.replace(/\$/g,""))
		});
	}

	// Show the URL configuration dialog
	function urlConf(){
		var url = GM_getValue(storageKeys.url)
		ctcmi.html(
			"<p>Comments will be loaded from these URLs when saved and once a day afterwards. Multiple URLs can be specified, each on its own line.  Github raw URLs have been whitelisted. Other URLs will ask for your permission.</p>"
		)
		if (url) ctcmi.append("<p>Remove all the URLs to be able to edit the comments in your browser.</p>")
		else ctcmi.append("<p>Using a URL will <b>overwrite</b> any edits to the comments you have made.</p>")
		ctcmi.append($('<textarea placeholder=https://raw.githubusercontent.com/user/repo/123/stack-exchange-comments.txt>').val(url))
		ctcmi.append($('<button>Save</button>').click(doneUrlConf))
		ctcmi.append($('<button>Cancel</button>').click(closeMenu))
		return false
	}

	// User clicked "Save" in URL configuration dialog
	function doneUrlConf(){
		GM_setValue(storageKeys.url, $(this).prev('textarea').val())
		// Force a load by removing the timestamp of the last load
		GM_deleteValue(storageKeys.lastUpdate)
		loadStorageUrlComments()
		closeMenu()
	}

	// Look up the URL from local storage, fetch the URL
	// and parse the comment templates from it
	// unless it has already been done recently
	function loadStorageUrlComments(){
		var url = GM_getValue(storageKeys.url)
		if (!url) return
		var lu = GM_getValue(storageKeys.lastUpdate);
		if (lu && lu > Date.now() - 8600000) return
		loadComments(url)
	}

	// Hook into clicks for the entire page that should show a context menu
	// Only handle the clicks on comment input areas (don't prevent
	// the context menu from appearing in other places.)
	$(document).contextmenu(function(e){
		var target = $(e.target)
		if (target.is('.comments-link')){
			// The "Add a comment" link
			var parent = target.parents('.answer,#question,.question')
			// Show the comment text area
			target.trigger('click')
			// Bring up the context menu for it
			showMenu(parent.find('textarea'))
			e.preventDefault()
			return false
		} else if (target.closest('#review-action-Reject,label[for="review-action-Reject"]').length){
			// Suggested edit review queue - reject
			target.trigger('click')
			$('button.js-review-submit').trigger('click')
			setTimeout(function(){
				// Click "causes harm"
				$('#rejection-reason-0').trigger('click')
			},100)
			setTimeout(function(){
				showMenu($('#rejection-reason-0').parents('.flex--item').find('textarea'))
			},200)
			e.preventDefault()
			return false
		} else if (target.closest('#review-action-Unsalvageable,label[for="review-action-Unsalvageable"]').length){
			// Triage review queue - unsalvageable
			target.trigger('click')
			$('button.js-review-submit').trigger('click')
			showMenuInFlagDialog()
			e.preventDefault()
			return false
		} else if (target.is('.js-flag-post-link')){
			// the "Flag" link for a question or answer
			// Click it to show pop up
			target.trigger('click')
			showMenuInFlagDialog()
			e.preventDefault()
			return false
		} else if (target.closest('.js-comment-flag').length){
			// The flag icon next to a comment
			target.trigger('click')
			setTimeout(function(){
				// Click "Something else"
				$('#comment-flag-type-CommentOther').prop('checked',true).parents('.js-comment-flag-option').find('.js-required-comment').removeClass('d-none')
			},100)
			setTimeout(function(){
				showMenu($('#comment-flag-type-CommentOther').parents('.js-comment-flag-option').find('textarea'))
			},200)
			e.preventDefault()
			return false
		} else if (target.closest('#review-action-Close,label[for="review-action-Close"],#review-action-NeedsAuthorEdit,label[for="review-action-NeedsAuthorEdit"]').length){
			// Close votes review queue - close action
			// or Triage review queue - needs author edit action
			target.trigger('click')
			$('button.js-review-submit').trigger('click')
			showMenuInCloseDialog()
			e.preventDefault()
			return false
		} else if (target.is('.js-close-question-link')){
			// The "Close" link for a question
			target.trigger('click')
			showMenuInCloseDialog()
			e.preventDefault()
			return false
		} else if (target.is('textarea,input[type="text"]') && (!target.val() || target.val() == target[0].defaultValue)){
			// A text field that is blank or hasn't been modified
			var type = getType(target)
			if (type){
				// A text field for entering a comment
				showMenu(target)
				e.preventDefault()
				return false
			}
		}
	})

	function showMenuInFlagDialog(){
		// Wait for the popup
		setTimeout(function(){
			$('input[value="PostOther"]').trigger('click')
		},100)
		setTimeout(function(){
			showMenu($('input[value="PostOther"]').parents('label').find('textarea'))
		},200)
	}

	function showMenuInCloseDialog(){
		setTimeout(function(){
			$('#closeReasonId-SiteSpecific').trigger('click')
		},100)
		setTimeout(function(){
			$('#siteSpecificCloseReasonId-other').trigger('click')
		},200)
		setTimeout(function(){
			showMenu($('#siteSpecificCloseReasonId-other').parents('.js-popup-radio-action').find('textarea'))
		},300)
	}

	function filterComments(e){
		if (e.key === "Enter") {
			// Pressing enter in the comment filter
			// should insert the first visible comment
			insertComment.call($('.ctcm-title:visible').first())
			e.preventDefault()
			return false
		}
		if (e.key == "Escape"){
			closeMenu()
			e.preventDefault()
			return false
		}
		// Show comments that contain the filter (case-insensitive)
		var f = $(this).val().toLowerCase()
		$('.ctcm-comment').each(function(){
			var c = $(this).text().toLowerCase()
			$(this).toggle(c.includes(f))
		})
	}

	function showMenu(target){
		varCache={} // Clear the variable cache
		commentTextField=target
		var type = getType(target)
		var user = getUserClass()
		var site = getSite()
		var tags = getTags()
		ctcmi.html("")
		var filter=$('<input type=text placeholder="filter... (type then press enter to insert the first comment)">').keyup(filterComments).change(filterComments)
		ctcmi.append(filter)
		for (var i=0; i<comments.length; i++){
			if(commentMatches(comments[i], type, user, site, tags)){
				ctcmi.append(
					$('<div class=ctcm-comment>').append(
						$('<span class=ctcm-expand>\u25bc</span>').click(expandFullComment)
					).append(
						$('<h4 class=ctcm-title>').text(comments[i].title).click(insertComment)
					).append(
						$('<div class=ctcm-body>').text(fillVariables(comments[i].comment)).click(insertComment).attr('data-socvr',comments[i].socvr||"")
					)
				)
			}
		}
		var info = (hasVarWarn()||hasCommentWarn())?"⚠️":"ⓘ"
		ctcmi.append($('<button>Edit</button>').click(editComments))
		ctcmi.append($('<button>Cancel</button>').click(closeMenu))
		ctcmi.append($('<button class=right>').text(info).click(showInfo))
		target.parents('.popup,#modal-base,body').first().append(ctcmo)
		ctcmo.show()
		filter.focus()
	}

	function closeMenu(){
		ctcmo.hide()
		ctcmo.remove()
	}

	// Hook into clicks anywhere in the document
	// and listen for ones that related to our dialog
	$(document).click(function(e){
		// dialog is open
		if(ctcmo.is(':visible')){
			// Allow clicks on links in the dialog to have default behavior
			if($(e.target).is('a')) return true
			// click wasn't on the dialog itself
			if(!$(e.target).parents('#ctcm-back').length) closeMenu()
			// Clicks when the dialog are open belong to us,
			// prevent other things from happening
			e.preventDefault()
			return false
		}
	})
})();