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.

  1. // ==UserScript==
  2. // @name Stack Exchange comment template context menu
  3. // @namespace http://ostermiller.org/
  4. // @version 1.17.0
  5. // @description Adds a context menu (right click, long press, command click, etc) to comment boxes on Stack Exchange with customizable pre-written responses.
  6. // @match https://*.stackexchange.com/questions/*
  7. // @match https://*.stackexchange.com/review/*
  8. // @match https://*.stackexchange.com/admin/*
  9. // @match https://*.stackoverflow.com/*questions/*
  10. // @match https://*.stackoverflow.com/review/*
  11. // @match https://*.stackoverflow.com/admin/*
  12. // @match https://*.askubuntu.com/questions/*
  13. // @match https://*.askubuntu.com/review/*
  14. // @match https://*.askubuntu.com/admin/*
  15. // @match https://*.superuser.com/questions/*
  16. // @match https://*.superuser.com/review/*
  17. // @match https://*.superuser.com/admin/*
  18. // @match https://*.serverfault.com/questions/*
  19. // @match https://*.serverfault.com/review/*
  20. // @match https://*.serverfault.com/admin/*
  21. // @match https://*.mathoverflow.net/questions/*
  22. // @match https://*.mathoverflow.net/review/*
  23. // @match https://*.mathoverflow.net/admin/*
  24. // @match https://*.stackapps.com/questions/*
  25. // @match https://*.stackapps.com/review/*
  26. // @match https://*.stackapps.com/admin/*
  27. // @match https://*.stackoverflow.com/*staging-ground/*
  28. // @connect raw.githubusercontent.com
  29. // @connect *
  30. // @grant GM_addStyle
  31. // @grant GM_setValue
  32. // @grant GM_getValue
  33. // @grant GM_deleteValue
  34. // @grant GM_xmlhttpRequest
  35. // ==/UserScript==
  36. (function() {
  37. 'use strict'
  38.  
  39. // Access to JavaScript variables from the Stack Exchange site
  40. var $ = unsafeWindow.jQuery
  41.  
  42. // eg. physics.stackexchange.com -> physics
  43. function validateSite(s){
  44. var m = /^((?:meta\.)?[a-z0-9]+(?:\.meta)?)\.?[a-z0-9\.]*$/.exec(s.toLowerCase().trim().replace(/^(https?:\/\/)?(www\.)?/,""))
  45. if (!m) return null
  46. return m[1]
  47. }
  48.  
  49. function validateTag(s){
  50. return s.toLowerCase().trim().replace(/ +/g,"-")
  51. }
  52.  
  53. // eg hello-world, hello-worlds, hello world, hello worlds, and hw all map to hello-world
  54. function makeFilterMap(s){
  55. var m = {}
  56. s=s.split(/,/)
  57. for (var i=0; i<s.length; i++){
  58. // original
  59. m[s[i]] = s[i]
  60. // plural
  61. m[s[i]+"s"] = s[i]
  62. // with spaces
  63. m[s[i].replace(/-/g," ")] = s[i]
  64. // plural with spaces
  65. m[s[i].replace(/-/g," ")+"s"] = s[i]
  66. // abbreviation
  67. m[s[i].replace(/([a-z])[a-z]+(-|$)/g,"$1")] = s[i]
  68. }
  69. return m
  70. }
  71.  
  72. var userMapInput = "moderator,user"
  73. var userMap = makeFilterMap(userMapInput)
  74. function validateUser(s){
  75. return userMap[s.toLowerCase().trim()]
  76. }
  77.  
  78. var typeMapInput = "question,answer,edit-question,edit-answer,close-question,flag-comment,flag-question,flag-answer,decline-flag,helpful-flag,decline-flag-very-low-quality,helpful-flag-very-low-quality,decline-flag-not-an-answer,helpful-flag-not-an-answer,decline-flag-auto,helpful-flag-auto,decline-flag-plagiarism,helpful-flag-plagiarism,decline-flag-gai,helpful-flag-gai,decline-flag-sock,helpful-flag-sock,decline-flag-migration,helpful-flag-migration,decline-flag-custom,helpful-flag-custom,reject-edit"
  79. var typeMap = makeFilterMap(typeMapInput)
  80. typeMap.c = 'close-question'
  81. typeMap.close = 'close-question'
  82.  
  83. function loadComments(urls){
  84. loadCommentsRecursive([], urls.split(/[\r\n ]+/))
  85. }
  86.  
  87. function loadCommentsRecursive(aComments, aUrls){
  88. if (!aUrls.length) {
  89. if (aComments.length){
  90. comments = aComments
  91. storeComments()
  92. if(GM_getValue(storageKeys.url)){
  93. GM_setValue(storageKeys.lastUpdate, Date.now())
  94. }
  95. }
  96. return
  97. }
  98. var url = aUrls.pop()
  99. if (!url){
  100. loadCommentsRecursive(aComments, aUrls)
  101. return
  102. }
  103. console.log("Loading comments from " + url)
  104. GM_xmlhttpRequest({
  105. method: "GET",
  106. url: url,
  107. onload: function(r){
  108. var lComments = parseComments(r.responseText)
  109. if (!lComments || !lComments.length){
  110. alert("No comment templates loaded from " + url)
  111. } else {
  112. aComments = aComments.concat(lComments)
  113. }
  114. loadCommentsRecursive(aComments, aUrls)
  115. },
  116. onerror: function(){
  117. alert("Could not load comment templates from " + url)
  118. loadCommentsRecursive(aComments, aUrls)
  119. }
  120. })
  121. }
  122.  
  123. function validateType(s){
  124. return typeMap[s.toLowerCase().trim()]
  125. }
  126.  
  127. // Map of functions that clean up the filter-tags on comment templates
  128. var tagValidators = {
  129. tags: validateTag,
  130. sites: validateSite,
  131. users: validateUser,
  132. types: validateType
  133. }
  134.  
  135. var attributeValidators = {
  136. socvr: trim
  137. }
  138.  
  139. function trim(s){
  140. return s.trim()
  141. }
  142.  
  143. // Given a filter tag name and an array of filter tag values,
  144. // clean up and canonicalize each of them
  145. // Put them into a hash set (map each to true) for performant lookups
  146. function validateAllTagValues(tag, arr){
  147. var ret = {}
  148. for (var i=0; i<arr.length; i++){
  149. // look up the validation function for the filter tag type and call it
  150. var v = tagValidators[tag](arr[i])
  151. // Put it in the hash set
  152. if (v) ret[v]=1
  153. }
  154. if (Object.keys(ret).length) return ret
  155. return null
  156. }
  157.  
  158. function validateValues(tag, value){
  159. if (tag in tagValidators) return validateAllTagValues(tag, value.split(/,/))
  160. if (tag in attributeValidators) return attributeValidators[tag](value)
  161. return null
  162. }
  163.  
  164. // List of keys used for storage, centralized for multiple usages
  165. var storageKeys = {
  166. comments: "ctcm-comments",
  167. url: "ctcm-url",
  168. lastUpdate: "ctcm-last-update"
  169. }
  170.  
  171. // On-load, parse comment templates from local storage
  172. var comments = parseComments(GM_getValue(storageKeys.comments))
  173. // The download comment templates from URL if configured
  174. if(GM_getValue(storageKeys.url)){
  175. loadStorageUrlComments()
  176. } else if (!comments || !comments.length){
  177. // If there are NO comments, fetch the defaults
  178. loadComments("https://raw.githubusercontent.com/stephenostermiller/stack-exchange-comment-templates/master/default-templates.txt")
  179. }
  180.  
  181. function hasCommentWarn(){
  182. return checkCommentLengths().length > 0
  183. }
  184.  
  185. function commentWarnHtml(){
  186. var problems = checkCommentLengths()
  187. if (!problems.length) return $('<span>')
  188. var s = $("<ul>")
  189. for (var i=0; i<problems.length; i++){
  190. s.append($('<li>').text("⚠️ " + problems[i]))
  191. }
  192. return $('<div>').append($('<h3>').text("Problems")).append(s)
  193. }
  194.  
  195. function checkCommentLengths(){
  196. var problems = []
  197. for (var i=0; i<comments.length; i++){
  198. var c = comments[i]
  199. var length = c.comment.length;
  200. if (length > 600){
  201. problems.push("Comment template is too long (" + length + "/600): " + c.title)
  202. } else if (length > 500 && (!c.types || c.types['flag-question'] || c.types['flag-answer'])){
  203. problems.push("Comment template is too long for flagging posts (" + length + "/500): " + c.title)
  204. } else if (length > 300 && (!c.types || c.types['edit-question'] || c.types['edit-answer'])){
  205. problems.push("Comment template is too long for an edit (" + length + "/300): " + c.title)
  206. } else if (length > 200 && (!c.types || c.types['decline-flag'] || c.types['helpful-flag'])){
  207. problems.push("Comment template is too long for flag handling (" + length + "/200): " + c.title)
  208. } else if (length > 200 && (!c.types || c.types['flag-comment'])){
  209. problems.push("Comment template is too long for flagging comments (" + length + "/200): " + c.title)
  210. }
  211. }
  212. return problems
  213. }
  214.  
  215. // Serialize the comment templates into local storage
  216. function storeComments(){
  217. if (!comments || !comments.length) GM_deleteValue(storageKeys.comments)
  218. else GM_setValue(storageKeys.comments, exportComments())
  219. }
  220.  
  221. function parseJsonpComments(s){
  222. var cs = []
  223. var callback = function(o){
  224. for (var i=0; i<o.length; i++){
  225. var c = {
  226. title: o[i].name,
  227. comment: o[i].description
  228. }
  229. var m = /^(?:\[([A-Z,]+)\])\s*(.*)$/.exec(c.title);
  230. if (m){
  231. c.title=m[2]
  232. c.types=validateValues("types",m[1])
  233. }
  234. if (c && c.title && c.comment) cs.push(c)
  235. }
  236. }
  237. eval(s)
  238. return cs
  239. }
  240.  
  241. function parseComments(s){
  242. if (!s) return []
  243. if (s.startsWith("callback(")) return parseJsonpComments(s)
  244. var lines = s.split(/\n|\r|\r\n/)
  245. var c, m, cs = []
  246. for (var i=0; i<lines.length; i++){
  247. var line = lines[i].trim()
  248. if (!line){
  249. // Blank line indicates end of comment
  250. if (c && c.title && c.comment) cs.push(c)
  251. c=null
  252. } else {
  253. // Comment template title
  254. // Starts with #
  255. // May contain type filter tag abbreviations (for compat with SE-AutoReviewComments)
  256. // eg # Comment title
  257. // eg ### [Q,A] Comment title
  258. m = /^#+\s*(?:\[([A-Z,]+)\])?\s*(.*)$/.exec(line);
  259. if (m){
  260. // Stash previous comment if it wasn't already ended by a new line
  261. if (c && c.title && c.comment) cs.push(c)
  262. // Start a new comment with title
  263. c={title:m[2]}
  264. // Handle type filter tags if they exist
  265. if (m[1]) c.types=validateValues("types",m[1])
  266. } else if (c) {
  267. // Already started parsing a comment, look for filter tags and comment body
  268. m = /^(sites|types|users|tags|socvr)\:\s*(.*)$/.exec(line);
  269. if (m){
  270. // Add filter tags
  271. c[m[1]]=validateValues(m[1],m[2])
  272. } else {
  273. // Comment body (join multiple lines with spaces)
  274. if (c.comment) c.comment=c.comment+" "+line
  275. else c.comment=line
  276. }
  277. } else {
  278. // No comment started, didn't find a comment title
  279. console.log("Could not parse line from comment templates: " + line)
  280. }
  281. }
  282. }
  283. // Stash the last comment if it isn't followed by a new line
  284. if (c && c.title && c.comment) cs.push(c)
  285. return cs
  286. }
  287.  
  288. function sort(arr){
  289. if (!(arr instanceof Array)) arr = Object.keys(arr)
  290. arr.sort()
  291. return arr
  292. }
  293.  
  294. function exportComments(){
  295. var s ="";
  296. for (var i=0; i<comments.length; i++){
  297. var c = comments[i]
  298. s += "# " + c.title + "\n"
  299. s += c.comment + "\n"
  300. if (c.types) s += "types: " + sort(c.types).join(", ") + "\n"
  301. if (c.sites) s += "sites: " + sort(c.sites).join(", ") + "\n"
  302. if (c.users) s += "users: " + sort(c.users).join(", ") + "\n"
  303. if (c.tags) s += "tags: " + sort(c.tags).join(", ") + "\n"
  304. if (c.socvr) s += "socvr: " + c.socvr + "\n"
  305. s += "\n"
  306. }
  307. return s;
  308. }
  309.  
  310. // inner lightbox content area
  311. var ctcmi = $('<div id=ctcm-menu>')
  312. // outer translucent lightbox background that covers the whole page
  313. var ctcmo = $('<div id=ctcm-back>').append(ctcmi)
  314. 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)}")
  315. 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}")
  316. GM_addStyle(".ctcm-body{display:none;background:var(--black-050);padding:.3em;cursor: pointer;")
  317. GM_addStyle(".ctcm-expand{float:right;cursor: pointer;}")
  318. GM_addStyle(".ctcm-title{margin-top:.3em;cursor: pointer;}")
  319. GM_addStyle("#ctcm-menu textarea{width:90vw;min-width:300px;max-width:1000px;height:60vh;resize:both;display:block}")
  320. GM_addStyle("#ctcm-menu input[type='text']{width:90vw;min-width:300px;max-width:1000px;display:block}")
  321. GM_addStyle("#ctcm-menu button{margin-top:1em;margin-right:.5em}")
  322. GM_addStyle("#ctcm-menu button.right{float:right}")
  323. GM_addStyle("#ctcm-menu h3{margin:.5em auto;font-size: 150%;}")
  324.  
  325. // Node input: text field where content can be written.
  326. // Used for filter tags to know which comment templates to show in which contexts.
  327. // Also used for knowing which clicks should show the context menu,
  328. // if a type isn't returned by this method, no menu will show up
  329. function getType(node){
  330. var prefix = "";
  331.  
  332. // Most of these rules use properties of the node or the node's parents
  333. // to deduce their context
  334.  
  335. if (!node.is('.js-comment-text-input,.js-comments-menu')){
  336. if (node.is('.js-rejection-reason-custom')) return "reject-edit"
  337. if (node.parents('.js-comment-flag-option').length) return "flag-comment"
  338. if (node.parents('.js-flagged-post').length){
  339. var type = /decline/.exec(node.attr('placeholder'))?"decline-flag":"helpful-flag"
  340. var text = node.closest('.js-flagged-post').find('.js-flag-text').text()
  341. if (/^Very low quality/.exec(text)) type += "-very-low-quality"
  342. else if (/^Not an answer/.exec(text)) type += "-not-an-answer"
  343. else if (/\(auto\)/.exec(text)) type += "-auto"
  344. else if (/^Plagiarism/.exec(text)) type += "-plagiarism"
  345. else if (/\b(generated|chatgpt|chatbot|gpt|gai|ai|aigc|llm)\b/i.exec(text)) type += "-gai"
  346. else if (/\b(sock|sockpuppet)\b/i.exec(text)) type += "-sock"
  347. else if (/\b(move|moved|migrate|migrated|belongs)\b/i.exec(text)) type += "-migration"
  348. else type += "-custom"
  349. return type
  350. }
  351. if (node.parents('.site-specific-pane').length) prefix = "close-"
  352. else if (node.parents('.mod-attention-subform').length) prefix = "flag-"
  353. else if (node.is('.edit-comment,#edit-comment')) prefix = "edit-"
  354. else if(node.is('.js-comment-text-input')) prefix = ""
  355. else return null
  356. }
  357.  
  358. if (node.parents('#question,.question').length) return prefix + "question"
  359. if (node.parents('#answers,.answer').length) return prefix + "answer"
  360.  
  361. // Staging Ground
  362. if (location.pathname.startsWith("/staging-ground") && node.is(".js-comment-text-input")) return "question";
  363.  
  364. // Fallback for single post edit page
  365. if (node.parents('.post-form').find('h2:last').text()=='Question') return prefix + "question"
  366. if (node.parents('.post-form').find('h2:last').text()=='Answer') return prefix + "answer"
  367.  
  368. return null
  369. }
  370.  
  371. // Mostly moderator or non-moderator (user.)
  372. // Not-logged in and low rep users are not able to comment much
  373. // and are unlikely to use this tool, no need to identify them
  374. // and give them special behavior.
  375. // Maybe add a class for staff in the future?
  376. var userclass
  377. function getUserClass(){
  378. if (!userclass){
  379. if ($('.js-mod-inbox-button').length) userclass="moderator"
  380. else if ($('.s-topbar--item.s-user-card').length) userclass="user"
  381. else userclass="anonymous"
  382. }
  383. return userclass
  384. }
  385.  
  386. // The Stack Exchange site this is run on (just the subdomain, eg "stackoverflow")
  387. var site
  388. function getSite(){
  389. if(!site) site=validateSite(location.hostname)
  390. return site
  391. }
  392.  
  393. // Which tags are on the question currently being viewed
  394. var tags
  395. function getTags(){
  396. if(!tags) tags=$.map($('.post-taglist .post-tag'),function(tag){return $(tag).text()})
  397. return tags
  398. }
  399.  
  400. // The id of the question currently being viewed
  401. function getQuestionId(){
  402. var id = $('.question').attr('data-questionid')
  403. if (!id){
  404. var l = $('.answer-hyperlink')
  405. if (l.length) id=l.attr('href').replace(/^\/questions\/([0-9]+).*/,"$1")
  406. }
  407. if (!id) id="-"
  408. return id
  409. }
  410.  
  411. // The human readable name of the current Stack Exchange site
  412. function getSiteName(){
  413. return $('meta[property="og:site_name"]').attr('content').replace(/ ?Stack Exchange/, "")
  414. }
  415.  
  416. // The Stack Exchange user id for the person using this tool
  417. function getMyUserId() {
  418. return $('a.s-topbar--item.s-user-card').attr('href').replace(/^\/users\/([0-9]+)\/.*/,"$1")
  419. }
  420.  
  421. // The Stack Exchange user name for the person using this tool
  422. function getMyName() {
  423. var n=$('header .s-avatar[title]').attr('title')
  424. if (!n) return "-"
  425. return n.replace(/ /g,"")
  426. }
  427.  
  428.  
  429. // The full host name of the Stack Exchange site
  430. function getSiteUrl(){
  431. return location.hostname
  432. }
  433.  
  434. // Store the comment text field that was clicked on
  435. // so that it can be filled with the comment template
  436. var commentTextField
  437.  
  438. // Insert the comment template into the text field
  439. // called when a template is clicked in the dialog box
  440. // so "this" refers to the clicked item
  441. function insertComment(){
  442. // The comment to insert is stored in a div
  443. // near the item that was clicked
  444. var body = $(this).parent().children('.ctcm-body')
  445. var socvr = body.attr('data-socvr')
  446. if (socvr){
  447. var url = "//" + getSiteUrl() + "/questions/" + getQuestionId()
  448. var title = $('h1').first().text()
  449. title = new Option(title).innerHTML
  450. $('#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>`))
  451. }
  452. var cmt = body.text()
  453.  
  454. // Put in the comment
  455. commentTextField.val(cmt).focus().trigger("change").trigger("input")
  456.  
  457. // highlight place for additional input,
  458. // if specified in the template
  459. var typeHere="[type here]"
  460. var typeHereInd = cmt.indexOf(typeHere)
  461. if (typeHereInd >= 0) commentTextField[0].setSelectionRange(typeHereInd, typeHereInd + typeHere.length)
  462.  
  463. closeMenu()
  464. }
  465.  
  466. // User clicked on the expand icon in the dialog
  467. // to show the full text of a comment
  468. function expandFullComment(){
  469. $(this).parent().children('.ctcm-body').show()
  470. $(this).hide()
  471. }
  472.  
  473. // Apply comment tag filters
  474. // For a given comment, say whether it
  475. // should be shown given the current context
  476. function commentMatches(comment, type, user, site, tags){
  477. if (comment.types){
  478. var isType = false
  479. while(type){
  480. if (comment.types[type]) isType = true
  481. type = type.replace(/\-?[^\-]*$/,"")
  482. }
  483. if (!isType) return false
  484. }
  485. if (comment.users && !comment.users[user]) return false
  486. if (comment.sites && !comment.sites[site]) return false
  487. if (comment.tags){
  488. var hasTag = false
  489. for(var i=0; tags && i<tags.length; i++){
  490. if (comment.tags[tags[i]]) hasTag=true
  491. }
  492. if(!hasTag) return false
  493. }
  494. return true
  495. }
  496.  
  497. // User clicked "Save" when editing the list of comment templates
  498. function doneEditing(){
  499. comments = parseComments($(this).prev('textarea').val())
  500. storeComments()
  501. closeMenu()
  502. }
  503.  
  504. // Show the edit comment dialog
  505. function editComments(){
  506. // Pointless to edit comments that will just get overwritten
  507. // If there is a URL, only allow the URL to be edited
  508. if(GM_getValue(storageKeys.url)) return urlConf()
  509. ctcmi.html(
  510. "<pre># Comment title\n"+
  511. "Comment body\n"+
  512. "types: "+typeMapInput.replace(/,/g, ", ")+"\n"+
  513. "users: "+userMapInput.replace(/,/g, ", ")+"\n"+
  514. "sites: stackoverflow, physics, meta.stackoverflow, physics.meta, etc\n"+
  515. "tags: javascript, python, etc\n"+
  516. "socvr: Message for Stack Overflow close vote reviews chat</pre>"+
  517. "<p>types, users, sites, tags, and socvr are optional.</p>"
  518. )
  519. .append($('<textarea>').val(exportComments()))
  520. .append($('<button>Save</button>').click(doneEditing))
  521. .append($('<button>Cancel</button>').click(closeMenu))
  522. .append($('<button>From URL...</button>').click(urlConf))
  523. return false
  524. }
  525.  
  526. // Show info
  527. function showInfo(){
  528. ctcmi.html(
  529. "<div><h2><a target=_blank href=//github.com/stephenostermiller/stack-exchange-comment-templates>Stack Exchange Comment Templates Context Menu</a></h2></div>"
  530. )
  531. .append(commentWarnHtml())
  532. .append(htmlVars())
  533. .append($('<button>Cancel</button>').click(closeMenu))
  534. return false
  535. }
  536. function getAuthorNode(postNode){
  537. return postNode.find('.post-signature .user-details[itemprop="author"]')
  538. }
  539.  
  540. function getOpNode(){
  541. return getAuthorNode($('#question,.question'))
  542. }
  543.  
  544. function getUserNodeId(node){
  545. if (!node) return "-"
  546. var link = node.find('a')
  547. if (!link.length) return "-"
  548. var href = link.attr('href')
  549. if (!href) return "-"
  550. return href.replace(/[^0-9]+/g, "")
  551. }
  552.  
  553. function getOpId(){
  554. return getUserNodeId(getOpNode())
  555. }
  556.  
  557. function getUserNodeName(node){
  558. if (!node) return "-"
  559. var link = node.find('a')
  560. if (!link.length) return "-"
  561. // Remove spaces from user names so that they can be used in @name references
  562. return link.text().replace(/ /g,"")
  563. }
  564.  
  565. function getOpName(){
  566. return getUserNodeName(getOpNode())
  567. }
  568.  
  569. function getUserNodeRep(node){
  570. if (!node) return "-"
  571. var r = node.find('.reputation-score')
  572. if (!r.length) return "-"
  573. return r.text()
  574. }
  575.  
  576. function getOpRep(){
  577. return getUserNodeRep(getOpNode())
  578. }
  579.  
  580. function getPostNode(){
  581. return commentTextField.parents('#question,.question,.answer')
  582. }
  583.  
  584. function getPostAuthorNode(){
  585. return getAuthorNode(getPostNode())
  586. }
  587.  
  588. function getAuthorId(){
  589. return getUserNodeId(getPostAuthorNode())
  590. }
  591.  
  592. function getAuthorName(){
  593. return getUserNodeName(getPostAuthorNode())
  594. }
  595.  
  596. function getAuthorRep(){
  597. return getUserNodeRep(getPostAuthorNode())
  598. }
  599.  
  600. function getPostId(){
  601. var postNode = getPostNode();
  602. if (!postNode.length) return "-"
  603. if (postNode.attr('data-questionid')) return postNode.attr('data-questionid')
  604. if (postNode.attr('data-answerid')) return postNode.attr('data-answerid')
  605. return "-"
  606. }
  607.  
  608. // Map of variables to functions that return their replacements
  609. var varMap = {
  610. 'SITENAME': getSiteName,
  611. 'SITEURL': getSiteUrl,
  612. 'MYUSERID': getMyUserId,
  613. 'MYNAME': getMyName,
  614. 'QUESTIONID': getQuestionId,
  615. 'OPID': getOpId,
  616. 'OPNAME': getOpName,
  617. 'OPREP': getOpRep,
  618. 'POSTID': getPostId,
  619. 'AUTHORID': getAuthorId,
  620. 'AUTHORNAME': getAuthorName,
  621. 'AUTHORREP': getAuthorRep
  622. }
  623.  
  624. // Cache variables so they don't have to be looked up for every single question
  625. var varCache={}
  626.  
  627. function getCachedVar(key){
  628. if (!varCache[key]) varCache[key] = varMap[key]()
  629. return varCache[key]
  630. }
  631.  
  632. function hasVarWarn(){
  633. var varnames = Object.keys(varMap)
  634. for (var i=0; i<varnames.length; i++){
  635. if (getCachedVar(varnames[i]).match(/^-?$/)) return true
  636. }
  637. return false
  638. }
  639.  
  640. function htmlVars(){
  641. var n = $("<ul>")
  642. var varnames = Object.keys(varMap)
  643. for (var i=0; i<varnames.length; i++){
  644. var li=$("<li>")
  645. var val = getCachedVar(varnames[i])
  646. if (val.match(/^-?$/)) li.append($("<span>").text("⚠️ "))
  647. li.append($("<b>").text(varnames[i])).append($("<span>").text(": ")).append($("<span>").text(val))
  648. n.append(li)
  649. }
  650. return $('<div>').append($('<h3>').text("Variables")).append(n)
  651. }
  652.  
  653. // Build regex to find variables from keys of map
  654. var varRegex = new RegExp('\\$('+Object.keys(varMap).join('|')+')\\$?', 'g')
  655. function fillVariables(s){
  656. // Perform the variable replacement
  657. return s.replace(varRegex, function (m) {
  658. // Remove $ from variable name
  659. return getCachedVar(m.replace(/\$/g,""))
  660. });
  661. }
  662.  
  663. // Show the URL configuration dialog
  664. function urlConf(){
  665. var url = GM_getValue(storageKeys.url)
  666. ctcmi.html(
  667. "<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>"
  668. )
  669. if (url) ctcmi.append("<p>Remove all the URLs to be able to edit the comments in your browser.</p>")
  670. else ctcmi.append("<p>Using a URL will <b>overwrite</b> any edits to the comments you have made.</p>")
  671. ctcmi.append($('<textarea placeholder=https://raw.githubusercontent.com/user/repo/123/stack-exchange-comments.txt>').val(url))
  672. ctcmi.append($('<button>Save</button>').click(doneUrlConf))
  673. ctcmi.append($('<button>Cancel</button>').click(closeMenu))
  674. return false
  675. }
  676.  
  677. // User clicked "Save" in URL configuration dialog
  678. function doneUrlConf(){
  679. GM_setValue(storageKeys.url, $(this).prev('textarea').val())
  680. // Force a load by removing the timestamp of the last load
  681. GM_deleteValue(storageKeys.lastUpdate)
  682. loadStorageUrlComments()
  683. closeMenu()
  684. }
  685.  
  686. // Look up the URL from local storage, fetch the URL
  687. // and parse the comment templates from it
  688. // unless it has already been done recently
  689. function loadStorageUrlComments(){
  690. var url = GM_getValue(storageKeys.url)
  691. if (!url) return
  692. var lu = GM_getValue(storageKeys.lastUpdate);
  693. if (lu && lu > Date.now() - 8600000) return
  694. loadComments(url)
  695. }
  696.  
  697. // Hook into clicks for the entire page that should show a context menu
  698. // Only handle the clicks on comment input areas (don't prevent
  699. // the context menu from appearing in other places.)
  700. $(document).contextmenu(function(e){
  701. var target = $(e.target)
  702. if (target.is('.comments-link')){
  703. // The "Add a comment" link
  704. var parent = target.parents('.answer,#question,.question')
  705. // Show the comment text area
  706. target.trigger('click')
  707. // Bring up the context menu for it
  708. showMenu(parent.find('textarea'))
  709. e.preventDefault()
  710. return false
  711. } else if (target.closest('#review-action-Reject,label[for="review-action-Reject"]').length){
  712. // Suggested edit review queue - reject
  713. target.trigger('click')
  714. $('button.js-review-submit').trigger('click')
  715. setTimeout(function(){
  716. // Click "causes harm"
  717. $('#rejection-reason-0').trigger('click')
  718. },100)
  719. setTimeout(function(){
  720. showMenu($('#rejection-reason-0').parents('.flex--item').find('textarea'))
  721. },200)
  722. e.preventDefault()
  723. return false
  724. } else if (target.closest('#review-action-Unsalvageable,label[for="review-action-Unsalvageable"]').length){
  725. // Triage review queue - unsalvageable
  726. target.trigger('click')
  727. $('button.js-review-submit').trigger('click')
  728. showMenuInFlagDialog()
  729. e.preventDefault()
  730. return false
  731. } else if (target.is('.js-flag-post-link')){
  732. // the "Flag" link for a question or answer
  733. // Click it to show pop up
  734. target.trigger('click')
  735. showMenuInFlagDialog()
  736. e.preventDefault()
  737. return false
  738. } else if (target.closest('.js-comment-flag').length){
  739. // The flag icon next to a comment
  740. target.trigger('click')
  741. setTimeout(function(){
  742. // Click "Something else"
  743. $('#comment-flag-type-CommentOther').prop('checked',true).parents('.js-comment-flag-option').find('.js-required-comment').removeClass('d-none')
  744. },100)
  745. setTimeout(function(){
  746. showMenu($('#comment-flag-type-CommentOther').parents('.js-comment-flag-option').find('textarea'))
  747. },200)
  748. e.preventDefault()
  749. return false
  750. } else if (target.closest('#review-action-Close,label[for="review-action-Close"],#review-action-NeedsAuthorEdit,label[for="review-action-NeedsAuthorEdit"]').length){
  751. // Close votes review queue - close action
  752. // or Triage review queue - needs author edit action
  753. target.trigger('click')
  754. $('button.js-review-submit').trigger('click')
  755. showMenuInCloseDialog()
  756. e.preventDefault()
  757. return false
  758. } else if (target.is('.js-close-question-link')){
  759. // The "Close" link for a question
  760. target.trigger('click')
  761. showMenuInCloseDialog()
  762. e.preventDefault()
  763. return false
  764. } else if (target.is('.js-resolve-action')){
  765. // Flag handling Helpul... or Decline.. link
  766. target.trigger('click')
  767. setTimeout(function(){
  768. showMenu(target.closest('.js-flagged-post').find('.is-expanded input.js-feedback[type="text"]'))
  769. },100)
  770. e.preventDefault()
  771. return false
  772. } else if (target.is('textarea,input[type="text"]') && (!target.val() || target.val() == target[0].defaultValue)){
  773. // A text field that is blank or hasn't been modified
  774. var type = getType(target)
  775. if (type){
  776. // A text field for entering a comment
  777. showMenu(target)
  778. e.preventDefault()
  779. return false
  780. }
  781. } else if (target.closest('.js-reply-bar').length){
  782. // "Add a comment" button
  783. target.trigger('click')
  784. showMenu(target.closest('.js-reply-bar').parent().find('.js-comment-text-input'))
  785. e.preventDefault()
  786. return false
  787. }
  788. })
  789.  
  790. function showMenuInFlagDialog(){
  791. // Wait for the popup
  792. setTimeout(function(){
  793. $('input[value="PostOther"]').trigger('click')
  794. },100)
  795. setTimeout(function(){
  796. showMenu($('input[value="PostOther"]').parents('label').find('textarea'))
  797. },200)
  798. }
  799.  
  800. function showMenuInCloseDialog(){
  801. setTimeout(function(){
  802. $('#closeReasonId-SiteSpecific').trigger('click')
  803. },100)
  804. setTimeout(function(){
  805. $('#siteSpecificCloseReasonId-other').trigger('click')
  806. },200)
  807. setTimeout(function(){
  808. showMenu($('#siteSpecificCloseReasonId-other').parents('.js-popup-radio-action').find('textarea'))
  809. },300)
  810. }
  811.  
  812. function filterComments(e){
  813. if (e.key === "Enter") {
  814. // Pressing enter in the comment filter
  815. // should insert the first visible comment
  816. insertComment.call($('.ctcm-title:visible').first())
  817. e.preventDefault()
  818. return false
  819. }
  820. if (e.key == "Escape"){
  821. closeMenu()
  822. e.preventDefault()
  823. return false
  824. }
  825. // Show comments that contain the filter (case-insensitive)
  826. var f = $(this).val().toLowerCase()
  827. $('.ctcm-comment').each(function(){
  828. var c = $(this).text().toLowerCase()
  829. $(this).toggle(c.includes(f))
  830. })
  831. }
  832.  
  833. function showMenu(target){
  834. varCache={} // Clear the variable cache
  835. commentTextField=target
  836. var type = getType(target)
  837. var user = getUserClass()
  838. var site = getSite()
  839. var tags = getTags()
  840. ctcmi.html("")
  841. var filter=$('<input type=text placeholder="filter... (type then press enter to insert the first comment)">').keyup(filterComments).change(filterComments)
  842. ctcmi.append(filter)
  843. for (var i=0; i<comments.length; i++){
  844. if(commentMatches(comments[i], type, user, site, tags)){
  845. ctcmi.append(
  846. $('<div class=ctcm-comment>').append(
  847. $('<span class=ctcm-expand>\u25bc</span>').click(expandFullComment)
  848. ).append(
  849. $('<h4 class=ctcm-title>').text(comments[i].title).click(insertComment)
  850. ).append(
  851. $('<div class=ctcm-body>').text(fillVariables(comments[i].comment)).click(insertComment).attr('data-socvr',comments[i].socvr||"")
  852. )
  853. )
  854. }
  855. }
  856. var info = (hasVarWarn()||hasCommentWarn())?"⚠️":"ⓘ"
  857. ctcmi.append($('<button>Edit</button>').click(editComments))
  858. ctcmi.append($('<button>Cancel</button>').click(closeMenu))
  859. ctcmi.append($('<button class=right>').text(info).click(showInfo))
  860. target.parents('.popup,#modal-base,body').first().append(ctcmo)
  861. ctcmo.show()
  862. filter.focus()
  863. }
  864.  
  865. function closeMenu(){
  866. ctcmo.hide()
  867. ctcmo.remove()
  868. }
  869.  
  870. // Hook into clicks anywhere in the document
  871. // and listen for ones that related to our dialog
  872. $(document).click(function(e){
  873. // dialog is open
  874. if(ctcmo.is(':visible')){
  875. // Allow clicks on links in the dialog to have default behavior
  876. if($(e.target).is('a')) return true
  877. // click wasn't on the dialog itself
  878. if(!$(e.target).parents('#ctcm-back').length) closeMenu()
  879. // Clicks when the dialog are open belong to us,
  880. // prevent other things from happening
  881. e.preventDefault()
  882. return false
  883. }
  884. })
  885. })();