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