├── images ├── promo.png ├── logo128.png ├── logo16.png ├── logo32.png ├── logo48.png ├── logo512.png ├── logo64.png ├── marquee.png ├── screenshot.png ├── instructions.png ├── large-promo.png └── submission-screenshot.png ├── .env.example ├── .gitignore ├── .github ├── FUNDING.yml └── scripts │ ├── run-firefox.sh │ ├── sign-firefox.sh │ └── build.sh ├── Makefile ├── CONTRIBUTING.md ├── LICENSE ├── manifest-v2.json ├── manifest.json ├── README.md ├── vendor └── showdown.min.js └── script.js /images/promo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DenverCoder1/unedit-for-reddit/HEAD/images/promo.png -------------------------------------------------------------------------------- /images/logo128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DenverCoder1/unedit-for-reddit/HEAD/images/logo128.png -------------------------------------------------------------------------------- /images/logo16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DenverCoder1/unedit-for-reddit/HEAD/images/logo16.png -------------------------------------------------------------------------------- /images/logo32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DenverCoder1/unedit-for-reddit/HEAD/images/logo32.png -------------------------------------------------------------------------------- /images/logo48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DenverCoder1/unedit-for-reddit/HEAD/images/logo48.png -------------------------------------------------------------------------------- /images/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DenverCoder1/unedit-for-reddit/HEAD/images/logo512.png -------------------------------------------------------------------------------- /images/logo64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DenverCoder1/unedit-for-reddit/HEAD/images/logo64.png -------------------------------------------------------------------------------- /images/marquee.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DenverCoder1/unedit-for-reddit/HEAD/images/marquee.png -------------------------------------------------------------------------------- /images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DenverCoder1/unedit-for-reddit/HEAD/images/screenshot.png -------------------------------------------------------------------------------- /images/instructions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DenverCoder1/unedit-for-reddit/HEAD/images/instructions.png -------------------------------------------------------------------------------- /images/large-promo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DenverCoder1/unedit-for-reddit/HEAD/images/large-promo.png -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Mozilla API Key 2 | JWT_USER=user:YOUR_USER_ID 3 | 4 | # Mozilla API Secret 5 | JWT_SECRET=YOUR_SECRET 6 | -------------------------------------------------------------------------------- /images/submission-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DenverCoder1/unedit-for-reddit/HEAD/images/submission-screenshot.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated files 2 | web-ext-artifacts/ 3 | .web-extension-id 4 | dist/ 5 | *.zip 6 | 7 | # Environment 8 | .env 9 | 10 | # IDE 11 | .vscode 12 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [DenverCoder1] 2 | patreon: 3 | open_collective: 4 | ko_fi: 5 | tidelift: 6 | community_bridge: 7 | liberapay: 8 | issuehunt: 9 | otechie: 10 | custom: 11 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | include .env 2 | 3 | build: 4 | bash ./.github/scripts/build.sh 5 | 6 | run-firefox: 7 | bash ./.github/scripts/run-firefox.sh 8 | 9 | sign-firefox: 10 | export JWT_USER=$(JWT_USER) && \ 11 | export JWT_SECRET=$(JWT_SECRET) && \ 12 | bash ./.github/scripts/sign-firefox.sh 13 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | Contributions are welcome! Feel free to open an issue or submit a pull request if you have a way to improve this project. 4 | 5 | If you are making a significant change, please open an issue before creating a pull request. This will allow us to discuss the design and implementation. 6 | 7 | Make sure your request is meaningful and you have tested the app locally before submitting a pull request. 8 | 9 | ## Installation 10 | 11 | See the [Installation](https://github.com/DenverCoder1/Unedit-for-Reddit/blob/master/README.md#installation) section of the README for multiple ways to install the script. 12 | 13 | Using a userscript editor or by installing the script as an unpacked extension in development mode, you should be able to test your changes locally. 14 | -------------------------------------------------------------------------------- /.github/scripts/run-firefox.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | setup() { 4 | # Ensure files to move exist 5 | if [ ! -f manifest-v2.json ] || [ ! -f manifest.json ]; then 6 | echo "manifest-v2.json or manifest.json was not found. Please run this script from the root of the extension." 7 | exit 1 8 | fi 9 | # Copy the v2 manifest to use instead of the v3 manifest 10 | mv manifest.json manifest-v3.json && mv manifest-v2.json manifest.json 11 | # Run cleanup in case of Ctrl+C 12 | trap cleanup SIGINT 13 | } 14 | 15 | cleanup() { 16 | # Ensure files to move exist 17 | if [ ! -f manifest-v3.json ] || [ ! -f manifest.json ]; then 18 | echo "manifest-v3.json or manifest.json was not found. Please run this script from the root of the extension." 19 | exit 1 20 | fi 21 | # Copy the v3 manifest back to the v2 manifest 22 | mv manifest.json manifest-v2.json && mv manifest-v3.json manifest.json 23 | } 24 | 25 | run() { 26 | # Run extension in Firefox development mode 27 | web-ext run 28 | } 29 | 30 | setup 31 | run 32 | cleanup 33 | -------------------------------------------------------------------------------- /.github/scripts/sign-firefox.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | setup() { 4 | # Ensure files to move exist 5 | if [ ! -f manifest-v2.json ] || [ ! -f manifest.json ]; then 6 | echo "manifest-v2.json or manifest.json was not found. Please run this script from the root of the extension." 7 | exit 1 8 | fi 9 | # Copy the v2 manifest to use instead of the v3 manifest 10 | mv manifest.json manifest-v3.json && mv manifest-v2.json manifest.json 11 | # Run cleanup in case of Ctrl+C 12 | trap cleanup SIGINT 13 | } 14 | 15 | cleanup() { 16 | # Ensure files to move exist 17 | if [ ! -f manifest-v3.json ] || [ ! -f manifest.json ]; then 18 | echo "manifest-v3.json or manifest.json was not found. Please run this script from the root of the extension." 19 | exit 1 20 | fi 21 | # Copy the v3 manifest back to the v2 manifest 22 | mv manifest.json manifest-v2.json && mv manifest-v3.json manifest.json 23 | } 24 | 25 | sign() { 26 | # Sign the Firefox extension 27 | web-ext sign --api-key=$JWT_USER --api-secret=$JWT_SECRET 28 | } 29 | 30 | setup 31 | sign 32 | cleanup 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Jonah Lawrence 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Declare list of files to zip 4 | FILES='images/* vendor/* LICENSE manifest.json README.md script.js' 5 | 6 | # Create directory for zip files 7 | mkdir -p dist 8 | 9 | ############################ 10 | # Chrome Extension Archive # 11 | ############################ 12 | 13 | zip -r -X "dist/chrome-extension.zip" $FILES 14 | 15 | ############################# 16 | # Firefox Extension Archive # 17 | ############################# 18 | 19 | setup() { 20 | # Ensure files to move exist 21 | if [ ! -f manifest-v2.json ] || [ ! -f manifest.json ]; then 22 | echo "manifest-v2.json or manifest.json was not found. Please run this script from the root of the extension." 23 | exit 1 24 | fi 25 | # Copy the v2 manifest to use instead of the v3 manifest 26 | mv manifest.json manifest-v3.json && mv manifest-v2.json manifest.json 27 | # Run cleanup in case of Ctrl+C 28 | trap cleanup SIGINT 29 | } 30 | 31 | cleanup() { 32 | # Ensure files to move exist 33 | if [ ! -f manifest-v3.json ] || [ ! -f manifest.json ]; then 34 | echo "manifest-v3.json or manifest.json was not found. Please run this script from the root of the extension." 35 | exit 1 36 | fi 37 | # Copy the v3 manifest back to the v2 manifest 38 | mv manifest.json manifest-v2.json && mv manifest-v3.json manifest.json 39 | } 40 | 41 | setup 42 | zip -r -X "dist/firefox-extension.zip" $FILES 43 | cleanup 44 | -------------------------------------------------------------------------------- /manifest-v2.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Unedit and Undelete for Reddit", 4 | "description": "Show original comments and posts from before they were edited or removed", 5 | "version": "3.17.4", 6 | "content_scripts": [ 7 | { 8 | "run_at": "document_idle", 9 | "matches": [ 10 | "*://*.reddit.com/", 11 | "*://*.reddit.com/me/f/*", 12 | "*://*.reddit.com/message/*", 13 | "*://*.reddit.com/r/*", 14 | "*://*.reddit.com/user/*" 15 | ], 16 | "exclude_matches": [ 17 | "*://*.reddit.com/*/about/banned*", 18 | "*://*.reddit.com/*/about/contributors*", 19 | "*://*.reddit.com/*/about/edit*", 20 | "*://*.reddit.com/*/about/flair*", 21 | "*://*.reddit.com/*/about/log*", 22 | "*://*.reddit.com/*/about/moderators*", 23 | "*://*.reddit.com/*/about/muted*", 24 | "*://*.reddit.com/*/about/rules*", 25 | "*://*.reddit.com/*/about/stylesheet*", 26 | "*://*.reddit.com/*/about/traffic*", 27 | "*://*.reddit.com/*/wiki/*", 28 | "*://mod.reddit.com/*" 29 | ], 30 | "js": ["vendor/showdown.min.js", "script.js"] 31 | } 32 | ], 33 | "web_accessible_resources": ["vendor/showdown.min.js.map"], 34 | "page_action": { 35 | "default_icon": "images/logo64.png", 36 | "default_title": "Unedit and Undelete for Reddit" 37 | }, 38 | "icons": { 39 | "16": "images/logo16.png", 40 | "32": "images/logo32.png", 41 | "48": "images/logo48.png", 42 | "128": "images/logo128.png" 43 | }, 44 | "permissions": [ 45 | "*://*.pushshift.io/*", 46 | "storage" 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Unedit and Undelete for Reddit", 4 | "description": "Show original comments and posts from before they were edited or removed", 5 | "version": "3.17.4", 6 | "content_scripts": [ 7 | { 8 | "run_at": "document_idle", 9 | "matches": [ 10 | "*://*.reddit.com/", 11 | "*://*.reddit.com/me/f/*", 12 | "*://*.reddit.com/message/*", 13 | "*://*.reddit.com/r/*", 14 | "*://*.reddit.com/user/*" 15 | ], 16 | "exclude_matches": [ 17 | "*://*.reddit.com/*/about/banned*", 18 | "*://*.reddit.com/*/about/contributors*", 19 | "*://*.reddit.com/*/about/edit*", 20 | "*://*.reddit.com/*/about/flair*", 21 | "*://*.reddit.com/*/about/log*", 22 | "*://*.reddit.com/*/about/moderators*", 23 | "*://*.reddit.com/*/about/muted*", 24 | "*://*.reddit.com/*/about/rules*", 25 | "*://*.reddit.com/*/about/stylesheet*", 26 | "*://*.reddit.com/*/about/traffic*", 27 | "*://*.reddit.com/*/wiki/*", 28 | "*://mod.reddit.com/*" 29 | ], 30 | "js": ["vendor/showdown.min.js", "script.js"] 31 | } 32 | ], 33 | "web_accessible_resources": [ 34 | { 35 | "matches": ["*://*.reddit.com/*"], 36 | "resources": ["vendor/showdown.min.js.map"] 37 | } 38 | ], 39 | "action": { 40 | "default_icon": "images/logo64.png", 41 | "default_title": "Unedit and Undelete for Reddit" 42 | }, 43 | "icons": { 44 | "16": "images/logo16.png", 45 | "32": "images/logo32.png", 46 | "48": "images/logo48.png", 47 | "128": "images/logo128.png" 48 | }, 49 | "host_permissions": ["https://api.pushshift.io/reddit/search/*"], 50 | "permissions": ["storage"] 51 | } 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

`) element
262 |
263 | ### Changes in 3.0
264 |
265 | - Added support for deleted comments
266 |
267 | ### Changes in 2.0
268 |
269 | - The original comment is converted from markdown to HTML to show custom formatting
270 | - Support for self-text submissions (old Reddit only)
271 |
--------------------------------------------------------------------------------
/vendor/showdown.min.js:
--------------------------------------------------------------------------------
1 | /*! showdown v 2.1.0 - 21-04-2022 */
2 | !function(){function a(e){"use strict";var r={omitExtraWLInCodeBlocks:{defaultValue:!1,describe:"Omit the default extra whiteline added to code blocks",type:"boolean"},noHeaderId:{defaultValue:!1,describe:"Turn on/off generated header id",type:"boolean"},prefixHeaderId:{defaultValue:!1,describe:"Add a prefix to the generated header ids. Passing a string will prefix that string to the header id. Setting to true will add a generic 'section-' prefix",type:"string"},rawPrefixHeaderId:{defaultValue:!1,describe:'Setting this option to true will prevent showdown from modifying the prefix. This might result in malformed IDs (if, for instance, the " char is used in the prefix)',type:"boolean"},ghCompatibleHeaderId:{defaultValue:!1,describe:"Generate header ids compatible with github style (spaces are replaced with dashes, a bunch of non alphanumeric chars are removed)",type:"boolean"},rawHeaderId:{defaultValue:!1,describe:"Remove only spaces, ' and \" from generated header ids (including prefixes), replacing them with dashes (-). WARNING: This might result in malformed ids",type:"boolean"},headerLevelStart:{defaultValue:!1,describe:"The header blocks level start",type:"integer"},parseImgDimensions:{defaultValue:!1,describe:"Turn on/off image dimension parsing",type:"boolean"},simplifiedAutoLink:{defaultValue:!1,describe:"Turn on/off GFM autolink style",type:"boolean"},excludeTrailingPunctuationFromURLs:{defaultValue:!1,describe:"Excludes trailing punctuation from links generated with autoLinking",type:"boolean"},literalMidWordUnderscores:{defaultValue:!1,describe:"Parse midword underscores as literal underscores",type:"boolean"},literalMidWordAsterisks:{defaultValue:!1,describe:"Parse midword asterisks as literal asterisks",type:"boolean"},strikethrough:{defaultValue:!1,describe:"Turn on/off strikethrough support",type:"boolean"},tables:{defaultValue:!1,describe:"Turn on/off tables support",type:"boolean"},tablesHeaderId:{defaultValue:!1,describe:"Add an id to table headers",type:"boolean"},ghCodeBlocks:{defaultValue:!0,describe:"Turn on/off GFM fenced code blocks support",type:"boolean"},tasklists:{defaultValue:!1,describe:"Turn on/off GFM tasklist support",type:"boolean"},smoothLivePreview:{defaultValue:!1,describe:"Prevents weird effects in live previews due to incomplete input",type:"boolean"},smartIndentationFix:{defaultValue:!1,describe:"Tries to smartly fix indentation in es6 strings",type:"boolean"},disableForced4SpacesIndentedSublists:{defaultValue:!1,describe:"Disables the requirement of indenting nested sublists by 4 spaces",type:"boolean"},simpleLineBreaks:{defaultValue:!1,describe:"Parses simple line breaks as
(GFM Style)",type:"boolean"},requireSpaceBeforeHeadingText:{defaultValue:!1,describe:"Makes adding a space between `#` and the header text mandatory (GFM Style)",type:"boolean"},ghMentions:{defaultValue:!1,describe:"Enables github @mentions",type:"boolean"},ghMentionsLink:{defaultValue:"https://github.com/{u}",describe:"Changes the link generated by @mentions. Only applies if ghMentions option is enabled.",type:"string"},encodeEmails:{defaultValue:!0,describe:"Encode e-mail addresses through the use of Character Entities, transforming ASCII e-mail addresses into its equivalent decimal entities",type:"boolean"},openLinksInNewWindow:{defaultValue:!1,describe:"Open all links in new windows",type:"boolean"},backslashEscapesHTMLTags:{defaultValue:!1,describe:"Support for HTML Tag escaping. ex:
[^\r]+?<\/pre>)/gm,function(e,r){return r.replace(/^ /gm,"¨0").replace(/¨0/g,"")}),x.subParser("hashBlock")("\n"+e+"\n
",r,t)}),e=t.converter._dispatch("blockQuotes.after",e,r,t)}),x.subParser("codeBlocks",function(e,n,s){"use strict";e=s.converter._dispatch("codeBlocks.before",e,n,s);return e=(e=(e+="¨0").replace(/(?:\n\n|^)((?:(?:[ ]{4}|\t).*\n+)+)(\n*[ ]{0,3}[^ \t\n]|(?=¨0))/g,function(e,r,t){var a="\n",r=x.subParser("outdent")(r,n,s);return r=x.subParser("encodeCode")(r,n,s),r=""+(r=(r=(r=x.subParser("detab")(r,n,s)).replace(/^\n+/g,"")).replace(/\n+$/g,""))+(a=n.omitExtraWLInCodeBlocks?"":a)+"
",x.subParser("hashBlock")(r,n,s)+t})).replace(/¨0/,""),e=s.converter._dispatch("codeBlocks.after",e,n,s)}),x.subParser("codeSpans",function(e,n,s){"use strict";return e=(e=void 0===(e=s.converter._dispatch("codeSpans.before",e,n,s))?"":e).replace(/(^|[^\\])(`+)([^\r]*?[^`])\2(?!`)/gm,function(e,r,t,a){return a=(a=a.replace(/^([ \t]*)/g,"")).replace(/[ \t]*$/g,""),a=r+""+(a=x.subParser("encodeCode")(a,n,s))+"",a=x.subParser("hashHTMLSpans")(a,n,s)}),e=s.converter._dispatch("codeSpans.after",e,n,s)}),x.subParser("completeHTMLDocument",function(e,r,t){"use strict";if(!r.completeHTMLDocument)return e;e=t.converter._dispatch("completeHTMLDocument.before",e,r,t);var a,n="html",s="\n",o="",i='\n',l="",c="";for(a in void 0!==t.metadata.parsed.doctype&&(s="\n","html"!==(n=t.metadata.parsed.doctype.toString().toLowerCase())&&"html5"!==n||(i='')),t.metadata.parsed)if(t.metadata.parsed.hasOwnProperty(a))switch(a.toLowerCase()){case"doctype":break;case"title":o=""+t.metadata.parsed.title+" \n";break;case"charset":i="html"===n||"html5"===n?'\n':'\n';break;case"language":case"lang":l=' lang="'+t.metadata.parsed[a]+'"',c+='\n';break;default:c+='\n'}return e=s+"\n\n"+o+i+c+"\n\n"+e.trim()+"\n\n",e=t.converter._dispatch("completeHTMLDocument.after",e,r,t)}),x.subParser("detab",function(e,r,t){"use strict";return e=(e=(e=(e=(e=(e=t.converter._dispatch("detab.before",e,r,t)).replace(/\t(?=\t)/g," ")).replace(/\t/g,"¨A¨B")).replace(/¨B(.+?)¨A/g,function(e,r){for(var t=r,a=4-t.length%4,n=0;n/g,">"),e=t.converter._dispatch("encodeAmpsAndAngles.after",e,r,t)}),x.subParser("encodeBackslashEscapes",function(e,r,t){"use strict";return e=(e=(e=t.converter._dispatch("encodeBackslashEscapes.before",e,r,t)).replace(/\\(\\)/g,x.helper.escapeCharactersCallback)).replace(/\\([`*_{}\[\]()>#+.!~=|:-])/g,x.helper.escapeCharactersCallback),e=t.converter._dispatch("encodeBackslashEscapes.after",e,r,t)}),x.subParser("encodeCode",function(e,r,t){"use strict";return e=(e=t.converter._dispatch("encodeCode.before",e,r,t)).replace(/&/g,"&").replace(//g,">").replace(/([*_{}\[\]\\=~-])/g,x.helper.escapeCharactersCallback),e=t.converter._dispatch("encodeCode.after",e,r,t)}),x.subParser("escapeSpecialCharsWithinTagAttributes",function(e,r,t){"use strict";return e=(e=(e=t.converter._dispatch("escapeSpecialCharsWithinTagAttributes.before",e,r,t)).replace(/<\/?[a-z\d_:-]+(?:[\s]+[\s\S]+?)?>/gi,function(e){return e.replace(/(.)<\/?code>(?=.)/g,"$1`").replace(/([\\`*_~=|])/g,x.helper.escapeCharactersCallback)})).replace(/-]|-[^>])(?:[^-]|-[^-])*)--)>/gi,function(e){return e.replace(/([\\`*_~=|])/g,x.helper.escapeCharactersCallback)}),e=t.converter._dispatch("escapeSpecialCharsWithinTagAttributes.after",e,r,t)}),x.subParser("githubCodeBlocks",function(e,s,o){"use strict";return s.ghCodeBlocks?(e=o.converter._dispatch("githubCodeBlocks.before",e,s,o),e=(e=(e+="¨0").replace(/(?:^|\n)(?: {0,3})(```+|~~~+)(?: *)([^\s`~]*)\n([\s\S]*?)\n(?: {0,3})\1/g,function(e,r,t,a){var n=s.omitExtraWLInCodeBlocks?"":"\n";return a=x.subParser("encodeCode")(a,s,o),a=""+(a=(a=(a=x.subParser("detab")(a,s,o)).replace(/^\n+/g,"")).replace(/\n+$/g,""))+n+"
",a=x.subParser("hashBlock")(a,s,o),"\n\n¨G"+(o.ghCodeBlocks.push({text:e,codeblock:a})-1)+"G\n\n"})).replace(/¨0/,""),o.converter._dispatch("githubCodeBlocks.after",e,s,o)):e}),x.subParser("hashBlock",function(e,r,t){"use strict";return e=(e=t.converter._dispatch("hashBlock.before",e,r,t)).replace(/(^\n+|\n+$)/g,""),e="\n\n¨K"+(t.gHtmlBlocks.push(e)-1)+"K\n\n",e=t.converter._dispatch("hashBlock.after",e,r,t)}),x.subParser("hashCodeTags",function(e,n,s){"use strict";e=s.converter._dispatch("hashCodeTags.before",e,n,s);return e=x.helper.replaceRecursiveRegExp(e,function(e,r,t,a){t=t+x.subParser("encodeCode")(r,n,s)+a;return"¨C"+(s.gHtmlSpans.push(t)-1)+"C"},"]*>","","gim"),e=s.converter._dispatch("hashCodeTags.after",e,n,s)}),x.subParser("hashElement",function(e,r,t){"use strict";return function(e,r){return r=(r=(r=r.replace(/\n\n/g,"\n")).replace(/^\n/,"")).replace(/\n+$/g,""),r="\n\n¨K"+(t.gHtmlBlocks.push(r)-1)+"K\n\n"}}),x.subParser("hashHTMLBlocks",function(e,r,n){"use strict";e=n.converter._dispatch("hashHTMLBlocks.before",e,r,n);function t(e,r,t,a){return-1!==t.search(/\bmarkdown\b/)&&(e=t+n.converter.makeHtml(r)+a),"\n\n¨K"+(n.gHtmlBlocks.push(e)-1)+"K\n\n"}var a=["pre","div","h1","h2","h3","h4","h5","h6","blockquote","table","dl","ol","ul","script","noscript","form","fieldset","iframe","math","style","section","header","footer","nav","article","aside","address","audio","canvas","figure","hgroup","output","video","p"];r.backslashEscapesHTMLTags&&(e=e.replace(/\\<(\/?[^>]+?)>/g,function(e,r){return"<"+r+">"}));for(var s=0;s]*>)","im"),i="<"+a[s]+"\\b[^>]*>",l=""+a[s]+">";-1!==(c=x.helper.regexIndexOf(e,o));){var c=x.helper.splitAtIndex(e,c),u=x.helper.replaceRecursiveRegExp(c[1],t,i,l,"im");if(u===c[1])break;e=c[0].concat(u)}return e=e.replace(/(\n {0,3}(<(hr)\b([^<>])*?\/?>)[ \t]*(?=\n{2,}))/g,x.subParser("hashElement")(e,r,n)),e=(e=x.helper.replaceRecursiveRegExp(e,function(e){return"\n\n¨K"+(n.gHtmlBlocks.push(e)-1)+"K\n\n"},"^ {0,3}\x3c!--","--\x3e","gm")).replace(/(?:\n\n)( {0,3}(?:<([?%])[^\r]*?\2>)[ \t]*(?=\n{2,}))/g,x.subParser("hashElement")(e,r,n)),e=n.converter._dispatch("hashHTMLBlocks.after",e,r,n)}),x.subParser("hashHTMLSpans",function(e,r,t){"use strict";function a(e){return"¨C"+(t.gHtmlSpans.push(e)-1)+"C"}return e=(e=(e=(e=(e=t.converter._dispatch("hashHTMLSpans.before",e,r,t)).replace(/<[^>]+?\/>/gi,a)).replace(/<([^>]+?)>[\s\S]*?<\/\1>/g,a)).replace(/<([^>]+?)\s[^>]+?>[\s\S]*?<\/\1>/g,a)).replace(/<[^>]+?>/gi,a),e=t.converter._dispatch("hashHTMLSpans.after",e,r,t)}),x.subParser("unhashHTMLSpans",function(e,r,t){"use strict";e=t.converter._dispatch("unhashHTMLSpans.before",e,r,t);for(var a=0;a]*>\\s*]*>","^ {0,3}\\s* ","gim"),e=s.converter._dispatch("hashPreCodeTags.after",e,n,s)}),x.subParser("headers",function(e,n,s){"use strict";e=s.converter._dispatch("headers.before",e,n,s);var o=isNaN(parseInt(n.headerLevelStart))?1:parseInt(n.headerLevelStart),r=n.smoothLivePreview?/^(.+)[ \t]*\n={2,}[ \t]*\n+/gm:/^(.+)[ \t]*\n=+[ \t]*\n+/gm,t=n.smoothLivePreview?/^(.+)[ \t]*\n-{2,}[ \t]*\n+/gm:/^(.+)[ \t]*\n-+[ \t]*\n+/gm,r=(e=(e=e.replace(r,function(e,r){var t=x.subParser("spanGamut")(r,n,s),r=n.noHeaderId?"":' id="'+i(r)+'"',r="]*>/.test(c)&&(u=!0)}n[o]=c}return e=(e=(e=n.join("\n")).replace(/^\n+/g,"")).replace(/\n+$/g,""),t.converter._dispatch("paragraphs.after",e,r,t)}),x.subParser("runExtension",function(e,r,t,a){"use strict";return e.filter?r=e.filter(r,a.converter,t):e.regex&&((a=e.regex)instanceof RegExp||(a=new RegExp(a,"g")),r=r.replace(a,e.replace)),r}),x.subParser("spanGamut",function(e,r,t){"use strict";return e=t.converter._dispatch("spanGamut.before",e,r,t),e=x.subParser("codeSpans")(e,r,t),e=x.subParser("escapeSpecialCharsWithinTagAttributes")(e,r,t),e=x.subParser("encodeBackslashEscapes")(e,r,t),e=x.subParser("images")(e,r,t),e=x.subParser("anchors")(e,r,t),e=x.subParser("autoLinks")(e,r,t),e=x.subParser("simplifiedAutoLinks")(e,r,t),e=x.subParser("emoji")(e,r,t),e=x.subParser("underline")(e,r,t),e=x.subParser("italicsAndBold")(e,r,t),e=x.subParser("strikethrough")(e,r,t),e=x.subParser("ellipsis")(e,r,t),e=x.subParser("hashHTMLSpans")(e,r,t),e=x.subParser("encodeAmpsAndAngles")(e,r,t),r.simpleLineBreaks?/\n\n¨K/.test(e)||(e=e.replace(/\n+/g,"
\n")):e=e.replace(/ +\n/g,"
\n"),e=t.converter._dispatch("spanGamut.after",e,r,t)}),x.subParser("strikethrough",function(e,t,a){"use strict";return t.strikethrough&&(e=(e=a.converter._dispatch("strikethrough.before",e,t,a)).replace(/(?:~){2}([\s\S]+?)(?:~){2}/g,function(e,r){return r=r,""+(r=t.simplifiedAutoLink?x.subParser("simplifiedAutoLinks")(r,t,a):r)+""}),e=a.converter._dispatch("strikethrough.after",e,t,a)),e}),x.subParser("stripLinkDefinitions",function(i,l,c){"use strict";function e(e,r,t,a,n,s,o){return r=r.toLowerCase(),i.toLowerCase().split(r).length-1<2?e:(t.match(/^data:.+?\/.+?;base64,/)?c.gUrls[r]=t.replace(/\s/g,""):c.gUrls[r]=x.subParser("encodeAmpsAndAngles")(t,l,c),s?s+o:(o&&(c.gTitles[r]=o.replace(/"|'/g,""")),l.parseImgDimensions&&a&&n&&(c.gDimensions[r]={width:a,height:n}),""))}return i=(i=(i=(i+="¨0").replace(/^ {0,3}\[([^\]]+)]:[ \t]*\n?[ \t]*(data:.+?\/.+?;base64,[A-Za-z0-9+/=\n]+?)>?(?: =([*\d]+[A-Za-z%]{0,4})x([*\d]+[A-Za-z%]{0,4}))?[ \t]*\n?[ \t]*(?:(\n*)["|'(](.+?)["|')][ \t]*)?(?:\n\n|(?=¨0)|(?=\n\[))/gm,e)).replace(/^ {0,3}\[([^\]]+)]:[ \t]*\n?[ \t]*([^>\s]+)>?(?: =([*\d]+[A-Za-z%]{0,4})x([*\d]+[A-Za-z%]{0,4}))?[ \t]*\n?[ \t]*(?:(\n*)["|'(](.+?)["|')][ \t]*)?(?:\n+|(?=¨0))/gm,e)).replace(/¨0/,"")}),x.subParser("tables",function(e,y,P){"use strict";if(!y.tables)return e;function r(e){for(var r=e.split("\n"),t=0;t"+(n=x.subParser("spanGamut")(n,y,P))+"\n"));for(t=0;t"+x.subParser("spanGamut")(i,y,P)+"\n"));h.push(_)}for(var m=d,f=h,b="\n\n\n",w=m.length,k=0;k\n \n\n",k=0;k\n";for(var v=0;v\n"}return b+=" \n
\n"}return e=(e=(e=(e=P.converter._dispatch("tables.before",e,y,P)).replace(/\\(\|)/g,x.helper.escapeCharactersCallback)).replace(/^ {0,3}\|?.+\|.+\n {0,3}\|?[ \t]*:?[ \t]*(?:[-=]){2,}[ \t]*:?[ \t]*\|[ \t]*:?[ \t]*(?:[-=]){2,}[\s\S]+?(?:\n\n|¨0)/gm,r)).replace(/^ {0,3}\|.+\|[ \t]*\n {0,3}\|[ \t]*:?[ \t]*(?:[-=]){2,}[ \t]*:?[ \t]*\|[ \t]*\n( {0,3}\|.+\|[ \t]*\n)*(?:\n|¨0)/gm,r),e=P.converter._dispatch("tables.after",e,y,P)}),x.subParser("underline",function(e,r,t){"use strict";return r.underline?(e=t.converter._dispatch("underline.before",e,r,t),e=(e=r.literalMidWordUnderscores?(e=e.replace(/\b___(\S[\s\S]*?)___\b/g,function(e,r){return""+r+""})).replace(/\b__(\S[\s\S]*?)__\b/g,function(e,r){return""+r+""}):(e=e.replace(/___(\S[\s\S]*?)___/g,function(e,r){return/\S$/.test(r)?""+r+"":e})).replace(/__(\S[\s\S]*?)__/g,function(e,r){return/\S$/.test(r)?""+r+"":e})).replace(/(_)/g,x.helper.escapeCharactersCallback),t.converter._dispatch("underline.after",e,r,t)):e}),x.subParser("unescapeSpecialChars",function(e,r,t){"use strict";return e=(e=t.converter._dispatch("unescapeSpecialChars.before",e,r,t)).replace(/¨E(\d+)E/g,function(e,r){r=parseInt(r);return String.fromCharCode(r)}),e=t.converter._dispatch("unescapeSpecialChars.after",e,r,t)}),x.subParser("makeMarkdown.blockquote",function(e,r){"use strict";var t="";if(e.hasChildNodes())for(var a=e.childNodes,n=a.length,s=0;s ")}),x.subParser("makeMarkdown.codeBlock",function(e,r){"use strict";var t=e.getAttribute("language"),e=e.getAttribute("precodenum");return"```"+t+"\n"+r.preList[e]+"\n```"}),x.subParser("makeMarkdown.codeSpan",function(e){"use strict";return"`"+e.innerHTML+"`"}),x.subParser("makeMarkdown.emphasis",function(e,r){"use strict";var t="";if(e.hasChildNodes()){t+="*";for(var a=e.childNodes,n=a.length,s=0;s",e.hasAttribute("width")&&e.hasAttribute("height")&&(r+=" ="+e.getAttribute("width")+"x"+e.getAttribute("height")),e.hasAttribute("title")&&(r+=' "'+e.getAttribute("title")+'"'),r+=")"),r}),x.subParser("makeMarkdown.links",function(e,r){"use strict";var t="";if(e.hasChildNodes()&&e.hasAttribute("href")){for(var a=e.childNodes,n=a.length,t="[",s=0;s"),e.hasAttribute("title")&&(t+=' "'+e.getAttribute("title")+'"'),t+=")"}return t}),x.subParser("makeMarkdown.list",function(e,r,t){"use strict";var a="";if(!e.hasChildNodes())return"";for(var n=e.childNodes,s=n.length,o=e.getAttribute("start")||1,i=0;i"+r.preList[e]+""}),x.subParser("makeMarkdown.strikethrough",function(e,r){"use strict";var t="";if(e.hasChildNodes()){t+="~~";for(var a=e.childNodes,n=a.length,s=0;str>th"),s=e.querySelectorAll("tbody>tr"),o=0;o/g,"\\$1>")).replace(/^#/gm,"\\#")).replace(/^(\s*)([-=]{3,})(\s*)$/,"$1\\$2$3")).replace(/^( {0,3}\d+)\./gm,"$1\\.")).replace(/^( {0,3})([+-])/gm,"$1\\$2")).replace(/]([\s]*)\(/g,"\\]$1\\(")).replace(/^ {0,3}\[([\S \t]*?)]:/gm,"\\[$1]:")});"function"==typeof define&&define.amd?define(function(){"use strict";return x}):"undefined"!=typeof module&&module.exports?module.exports=x:this.showdown=x}.call(this);
3 | //# sourceMappingURL=showdown.min.js.map
4 |
--------------------------------------------------------------------------------
/script.js:
--------------------------------------------------------------------------------
1 | // ==UserScript==
2 | // @name Unedit and Undelete for Reddit
3 | // @namespace http://tampermonkey.net/
4 | // @version 3.17.4
5 | // @description Creates the option next to edited and deleted Reddit comments/posts to show the original comment from before it was edited
6 | // @author Jonah Lawrence (DenverCoder1)
7 | // @grant none
8 | // @require https://cdn.jsdelivr.net/npm/showdown@2.1.0/dist/showdown.min.js
9 | // @license MIT
10 | // @icon https://raw.githubusercontent.com/DenverCoder1/unedit-for-reddit/master/images/logo512.png
11 | // @match https://*.reddit.com/
12 | // @match https://*.reddit.com/me/f/*
13 | // @match https://*.reddit.com/message/*
14 | // @match https://*.reddit.com/r/*
15 | // @match https://*.reddit.com/user/*
16 | // @exclude https://*.reddit.com/*/about/banned*
17 | // @exclude https://*.reddit.com/*/about/contributors*
18 | // @exclude https://*.reddit.com/*/about/edit*
19 | // @exclude https://*.reddit.com/*/about/flair*
20 | // @exclude https://*.reddit.com/*/about/log*
21 | // @exclude https://*.reddit.com/*/about/moderators*
22 | // @exclude https://*.reddit.com/*/about/muted*
23 | // @exclude https://*.reddit.com/*/about/rules*
24 | // @exclude https://*.reddit.com/*/about/stylesheet*
25 | // @exclude https://*.reddit.com/*/about/traffic*
26 | // @exclude https://*.reddit.com/*/wiki/*
27 | // @exclude https://mod.reddit.com/*
28 | // ==/UserScript==
29 |
30 | /* jshint esversion: 8 */
31 |
32 | (function () {
33 | "use strict";
34 |
35 | /**
36 | * The current version of the script
37 | * @type {string}
38 | */
39 | const VERSION = "3.17.4";
40 |
41 | /**
42 | * Whether or not we are on old reddit and not redesign.
43 | * This will be set in the "load" event listener.
44 | * @type {boolean}
45 | */
46 | let isOldReddit = false;
47 |
48 | /**
49 | * Whether or not we are on compact mode.
50 | * This will be set in the "load" event listener.
51 | * @type {boolean}
52 | */
53 | let isCompact = false;
54 |
55 | /**
56 | * Timeout to check for new edited comments on page.
57 | * This will be updated when scrolling.
58 | * @type {number?}
59 | */
60 | let scriptTimeout = null;
61 |
62 | /**
63 | * The element that is currently requesting content
64 | * @type {Element?}
65 | */
66 | let currentLoading = null;
67 |
68 | /**
69 | * List of submission ids of edited posts.
70 | * Used on Reddit redesign since the submissions are not marked as such.
71 | * This is set in the "load" event listener from the Reddit JSON API.
72 | * @type {Array<{id: string, edited: float}>}
73 | */
74 | let editedSubmissions = [];
75 |
76 | /**
77 | * The current URL that is being viewed.
78 | * On Redesign, this can change without the user leaving page,
79 | * so we want to look for new edited submissions if it changes.
80 | * @type {string}
81 | */
82 | let currentURL = window.location.href;
83 |
84 | /**
85 | * Showdown markdown converter
86 | * @type {showdown.Converter}
87 | */
88 | const mdConverter = new showdown.Converter({
89 | tables: true,
90 | simplifiedAutoLink: true,
91 | literalMidWordUnderscores: true,
92 | strikethrough: true,
93 | ghCodeBlocks: true,
94 | disableForced4SpacesIndentedSublists: true,
95 | });
96 |
97 | /**
98 | * Logging methods for displaying formatted logs in the console.
99 | *
100 | * logging.info("This is an info message");
101 | * logging.warn("This is a warning message");
102 | * logging.error("This is an error message");
103 | * logging.table({a: 1, b: 2, c: 3});
104 | */
105 | const logging = {
106 | INFO: "info",
107 | WARN: "warn",
108 | ERROR: "error",
109 | TABLE: "table",
110 |
111 | /**
112 | * Log a message to the console
113 | * @param {string} level The console method to use e.g. "log", "info", "warn", "error", "table"
114 | * @param {...string} messages - Any number of messages to log
115 | */
116 | _format_log(level, ...messages) {
117 | const logger = level in console ? console[level] : console.log;
118 | logger(`%c[unedit-for-reddit] %c[${level.toUpperCase()}]`, "color: #00b6b6", "color: #888800", ...messages);
119 | },
120 |
121 | /**
122 | * Log an info message to the console
123 | * @param {...string} messages - Any number of messages to log
124 | */
125 | info(...messages) {
126 | logging._format_log(this.INFO, ...messages);
127 | },
128 |
129 | /**
130 | * Log a warning message to the console
131 | * @param {...string} messages - Any number of messages to log
132 | */
133 | warn(...messages) {
134 | logging._format_log(this.WARN, ...messages);
135 | },
136 |
137 | /**
138 | * Log an error message to the console
139 | * @param {...string} messages - Any number of messages to log
140 | */
141 | error(...messages) {
142 | logging._format_log(this.ERROR, ...messages);
143 | },
144 |
145 | /**
146 | * Log a table to the console
147 | * @param {Object} data - The table to log
148 | */
149 | table(data) {
150 | logging._format_log(this.TABLE, data);
151 | },
152 | };
153 |
154 | /**
155 | * Storage methods for saving and retrieving data from local storage.
156 | *
157 | * Use the storage API or chrome.storage API if available, otherwise use localStorage.
158 | *
159 | * storage.get("key").then((value) => { ... });
160 | * storage.get("key", "default value").then((value) => { ... });
161 | * storage.set("key", "value").then(() => { ... });
162 | */
163 | const storage = {
164 | /**
165 | * Get a value from storage
166 | * @param {string} key - The key to retrieve
167 | * @param {string?} defaultValue - The default value to return if the key does not exist
168 | * @returns {Promise} A promise that resolves with the value
169 | */
170 | get(key, defaultValue = null) {
171 | // retrieve from storage API
172 | if (storage._isBrowserStorageAvailable()) {
173 | logging.info(`Retrieving '${key}' from browser.storage.local`);
174 | return browser.storage.local.get(key).then((result) => {
175 | return result[key] || localStorage.getItem(key) || defaultValue;
176 | });
177 | } else if (storage._isChromeStorageAvailable()) {
178 | logging.info(`Retrieving '${key}' from chrome.storage.local`);
179 | return new Promise((resolve) => {
180 | chrome.storage.local.get(key, (result) => {
181 | resolve(result[key] || localStorage.getItem(key) || defaultValue);
182 | });
183 | });
184 | } else {
185 | logging.info(`Retrieving '${key}' from localStorage`);
186 | return Promise.resolve(localStorage.getItem(key) || defaultValue);
187 | }
188 | },
189 |
190 | /**
191 | * Set a value in storage
192 | * @param {string} key - The key to set
193 | * @param {string} value - The value to set
194 | * @returns {Promise} A promise that resolves when the value is set
195 | */
196 | set(key, value) {
197 | if (storage._isBrowserStorageAvailable()) {
198 | logging.info(`Storing '${key}' in browser.storage.local`);
199 | return browser.storage.local.set({ [key]: value });
200 | } else if (storage._isChromeStorageAvailable()) {
201 | logging.info(`Storing '${key}' in chrome.storage.local`);
202 | return new Promise((resolve) => {
203 | chrome.storage.local.set({ [key]: value }, resolve);
204 | });
205 | } else {
206 | logging.info(`Storing '${key}' in localStorage`);
207 | return Promise.resolve(localStorage.setItem(key, value));
208 | }
209 | },
210 |
211 | /**
212 | * Return whether browser.storage is available
213 | * @returns {boolean} Whether browser.storage is available
214 | */
215 | _isBrowserStorageAvailable() {
216 | return typeof browser !== "undefined" && browser.storage;
217 | },
218 |
219 | /**
220 | * Return whether chrome.storage is available
221 | * @returns {boolean} Whether chrome.storage is available
222 | */
223 | _isChromeStorageAvailable() {
224 | return typeof chrome !== "undefined" && chrome.storage;
225 | },
226 | };
227 |
228 | /**
229 | * Parse the URL for the submission ID and comment ID if it exists.
230 | * @returns {{submissionId: string|null, commentId: string|null}}
231 | */
232 | function parseURL() {
233 | const match = window.location.href.match(/\/comments\/([A-Za-z0-9]+)\/(?:.*?\/([A-Za-z0-9]+))?/);
234 | return {
235 | submissionId: (match && match[1]) || null,
236 | commentId: (match && match[2]) || null,
237 | };
238 | }
239 |
240 | /**
241 | * Find the ID of a comment or submission.
242 | * @param {Element} innerEl An element inside the comment.
243 | * @returns {string} The Reddit ID of the comment.
244 | */
245 | function getPostId(innerEl) {
246 | let postId = "";
247 | // redesign
248 | if (!isOldReddit) {
249 | const post = innerEl?.closest("[class*='t1_'], [class*='t3_']");
250 | if (post) {
251 | postId = Array.from(post.classList).filter(function (el) {
252 | return el.indexOf("t1_") > -1 || el.indexOf("t3_") > -1;
253 | })[0];
254 | } else {
255 | // if post not found, try to find the post id in the URL
256 | const parsedURL = parseURL();
257 | postId = parsedURL.commentId || parsedURL.submissionId || postId;
258 | }
259 | }
260 | // old reddit
261 | else if (!isCompact) {
262 | // old reddit comment
263 | postId = innerEl?.closest(".thing")?.id.replace("thing_", "");
264 | // old reddit submission
265 | if (!postId && isInSubmission(innerEl)) {
266 | const match = window.location.href.match(/comments\/([A-Za-z0-9]{5,8})\//);
267 | postId = match ? match[1] : null;
268 | // submission in list view
269 | if (!postId) {
270 | const thing = innerEl.closest(".thing");
271 | postId = thing?.id.replace("thing_", "");
272 | }
273 | }
274 | // if still not found, check for the .reportform element
275 | if (!postId) {
276 | postId = innerEl?.closest(".entry")?.querySelector(".reportform")?.className.replace(/.*t1/, "t1");
277 | }
278 | // if still not found check the url
279 | if (!postId) {
280 | const parsedURL = parseURL();
281 | postId = parsedURL.commentId || parsedURL.submissionId || postId;
282 | }
283 | // otherwise log an error
284 | if (!postId) {
285 | logging.error("Could not find post id", innerEl);
286 | postId = "";
287 | }
288 | }
289 | // compact
290 | else {
291 | const thing = innerEl?.closest(".thing");
292 | if (thing) {
293 | const idClass = [...thing.classList].find((c) => c.startsWith("id-"));
294 | postId = idClass ? idClass.replace("id-", "") : "";
295 | }
296 | // if not found, check the url
297 | if (!postId) {
298 | const parsedURL = parseURL();
299 | postId = parsedURL.commentId || parsedURL.submissionId || postId;
300 | }
301 | }
302 | // if the post appears on the page after the last 3 characters are removed, remove them
303 | const reMatch = postId.match(/(t1_\w+)\w{3}/) || postId.match(/(t3_\w+)\w{3}/);
304 | if (reMatch && document.querySelector(`.${reMatch[1]}, #thing_${reMatch[1]}`)) {
305 | postId = reMatch[1];
306 | }
307 | return postId;
308 | }
309 |
310 | /**
311 | * Get the container of the comment or submission body for appending the original comment to.
312 | * @param {string} postId The ID of the comment or submission
313 | * @returns {Element} The container element of the comment or submission body.
314 | */
315 | function getPostBodyElement(postId) {
316 | let bodyEl = null,
317 | baseEl = null;
318 | // redesign
319 | if (!isOldReddit) {
320 | baseEl = document.querySelector(`#${postId}, .Comment.${postId}`);
321 | // in post preview popups, the id will appear again but in #overlayScrollContainer
322 | const popupEl = document.querySelector(`#overlayScrollContainer .Post.${postId}`);
323 | baseEl = popupEl ? popupEl : baseEl;
324 | if (baseEl) {
325 | if (baseEl.getElementsByClassName("RichTextJSON-root").length > 0) {
326 | bodyEl = baseEl.getElementsByClassName("RichTextJSON-root")[0];
327 | } else if (isInSubmission(baseEl) && baseEl?.firstElementChild?.lastElementChild) {
328 | const classicBodyEl = baseEl.querySelector(`div[data-adclicklocation="background"]`);
329 | if (classicBodyEl) {
330 | bodyEl = classicBodyEl;
331 | } else {
332 | bodyEl = baseEl.firstElementChild.lastElementChild;
333 | if (bodyEl.childNodes.length === 1) {
334 | bodyEl = bodyEl.firstElementChild;
335 | }
336 | }
337 | } else {
338 | bodyEl = baseEl;
339 | }
340 | } else {
341 | // check for a paragraph with the text "That Comment Is Missing"
342 | const missingCommentEl = document.querySelectorAll(`div > div > svg:first-child + p`);
343 | [...missingCommentEl].some(function (el) {
344 | if (el.innerText === "That Comment Is Missing") {
345 | bodyEl = el.parentElement;
346 | return true;
347 | }
348 | });
349 | }
350 | }
351 | // old reddit
352 | else if (!isCompact) {
353 | // old reddit comments
354 | baseEl = document.querySelector(`form[id*='${postId}'] .md`);
355 | if (baseEl?.closest(".entry")) {
356 | bodyEl = baseEl;
357 | } else {
358 | baseEl = document.querySelector(".report-" + postId);
359 | bodyEl = baseEl
360 | ? baseEl.closest(".entry").querySelector(".usertext")
361 | : document.querySelector("p#noresults");
362 | }
363 | // old reddit submissions
364 | if (!bodyEl) {
365 | bodyEl =
366 | document.querySelector("div[data-url] .entry form .md") ||
367 | document.querySelector("div[data-url] .entry form .usertext-body") ||
368 | document.querySelector("div[data-url] .entry .top-matter");
369 | }
370 | // link view
371 | if (!bodyEl) {
372 | bodyEl = document.querySelector(`.id-${postId}`);
373 | }
374 | }
375 | // compact view
376 | else {
377 | bodyEl = document.querySelector(`.id-${postId} .md, .id-${postId} form.usertext`);
378 | // if not found, check for the .usertext element containing it as part of its id
379 | if (!bodyEl) {
380 | bodyEl = document.querySelector(".showOriginal")?.parentElement;
381 | }
382 | }
383 | return bodyEl;
384 | }
385 |
386 | /**
387 | * Check if surrounding elements imply element is in a selftext submission.
388 | * @param {Element} innerEl An element inside the post to check.
389 | * @returns {boolean} Whether or not the element is in a selftext submission
390 | */
391 | function isInSubmission(innerEl) {
392 | const selectors = [
393 | "a.thumbnail", // old reddit on profile page or list view
394 | "div[data-url]", // old reddit on submission page
395 | ".Post", // redesign
396 | ];
397 | // class list of .thing contains id-t3_...
398 | const thing = innerEl?.closest(".thing");
399 | if (thing) {
400 | const idClass = [...thing.classList].find((c) => c.startsWith("id-"));
401 | if (idClass) {
402 | return idClass.startsWith("id-t3_");
403 | }
404 | }
405 | return Boolean(innerEl.closest(selectors.join(", ")));
406 | }
407 |
408 | /**
409 | * Check if the element bounds are within the window bounds.
410 | * @param {Element} element The element to check
411 | * @returns {boolean} Whether or not the element is within the window
412 | */
413 | function isInViewport(element) {
414 | const rect = element.getBoundingClientRect();
415 | return (
416 | rect.top >= 0 &&
417 | rect.left >= 0 &&
418 | rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
419 | rect.right <= (window.innerWidth || document.documentElement.clientWidth)
420 | );
421 | }
422 |
423 | /**
424 | * Generate HTML from markdown for a comment or submission.
425 | * @param {string} postType The type of post - "comment" or "post" (submission)
426 | * @param {string} original The markdown to convert
427 | * @returns {string} The HTML of the markdown
428 | */
429 | function redditPostToHTML(postType, original) {
430 | // fix Reddit tables to have at least two dashes per cell in the alignment row
431 | let body = original.replace(/(?<=^\s*|\|\s*)(:?)-(:?)(?=\s*\|[-|\s:]*$)/gm, "$1--$2");
432 | // convert superscripts in the form "^(some text)" or "^text" to text
433 | const multiwordSuperscriptRegex = /\^\((.+?)\)/gm;
434 | while (multiwordSuperscriptRegex.test(body)) {
435 | body = body.replace(multiwordSuperscriptRegex, "$1");
436 | }
437 | const superscriptRegex = /\^(\S+)/gm;
438 | while (superscriptRegex.test(body)) {
439 | body = body.replace(superscriptRegex, "$1");
440 | }
441 | // convert user and subreddit mentions to links (can be /u/, /r/, u/, or r/)
442 | body = body.replace(/(?<=^|[^\w\/])(\/?)([ur]\/\w+)/gm, "[$1$2](/$2)");
443 | // add spaces after '>' to keep blockquotes (if it has '>!' ignore since that is spoilertext)
444 | body = body.replace(/^((?:>|>)+)(?=[^!\s])/gm, function (match, p1) {
445 | return p1.replace(/>/g, ">") + " ";
446 | });
447 | // convert markdown to HTML
448 | let html = mdConverter.makeHtml("\n\n### Original " + postType + ":\n\n" + body);
449 | // convert Reddit spoilertext
450 | html = html.replace(
451 | /(?<=^|\s|>)>!(.+?)!<(?=$|\s|<)/gm,
452 | "$1"
453 | );
454 | // replace with a zero-width space
455 | return html.replace(/​/g, "\u200B");
456 | }
457 |
458 | /**
459 | * Create a new paragraph containing the body of the original comment/post.
460 | * @param {Element} commentBodyElement The container element of the comment/post body.
461 | * @param {string} postType The type of post - "comment" or "post" (submission)
462 | * @param {object} postData The archived data of the original comment/post.
463 | * @param {Boolean} includeBody Whether or not to include the body of the original comment/post.
464 | */
465 | function showOriginalComment(commentBodyElement, postType, postData, includeBody) {
466 | const originalBody = typeof postData?.body === "string" ? postData.body : postData?.selftext;
467 | // create paragraph element
468 | const origBodyEl = document.createElement("p");
469 | origBodyEl.className = "og";
470 | // set text
471 | origBodyEl.innerHTML = includeBody ? redditPostToHTML(postType, originalBody) : "";
472 | // author and date details
473 | const detailsEl = document.createElement("div");
474 | detailsEl.style.fontSize = "12px";
475 | detailsEl.appendChild(document.createTextNode("Posted by "));
476 | const authorEl = document.createElement("a");
477 | authorEl.href = `/user/${postData.author}`;
478 | authorEl.innerText = postData.author;
479 | detailsEl.appendChild(authorEl);
480 | detailsEl.appendChild(document.createTextNode(" · "));
481 | const dateEl = document.createElement("a");
482 | dateEl.href = postData.permalink;
483 | dateEl.title = new Date(postData.created_utc * 1000).toString();
484 | dateEl.innerText = getRelativeTime(postData.created_utc);
485 | detailsEl.appendChild(dateEl);
486 | // append horizontal rule if the original body is shown
487 | if (includeBody) {
488 | origBodyEl.appendChild(document.createElement("hr"));
489 | }
490 | // append to original comment
491 | origBodyEl.appendChild(detailsEl);
492 | const existingOg = commentBodyElement.querySelector(".og");
493 | if (existingOg && includeBody) {
494 | // if there is an existing paragraph and this element contains the body, replace it
495 | existingOg.replaceWith(origBodyEl);
496 | } else if (!existingOg) {
497 | // if there is no existing paragraph, append it
498 | commentBodyElement.appendChild(origBodyEl);
499 | }
500 | // scroll into view
501 | setTimeout(function () {
502 | if (!isInViewport(origBodyEl)) {
503 | origBodyEl.scrollIntoView({ behavior: "smooth" });
504 | }
505 | }, 500);
506 | // Redesign
507 | if (!isOldReddit) {
508 | // Make sure collapsed submission previews are expanded to not hide the original comment.
509 | commentBodyElement.parentElement.style.maxHeight = "unset";
510 | }
511 | // Old reddit
512 | else {
513 | // If the comment is collapsed, expand it so the original comment is visible
514 | expandComment(commentBodyElement);
515 | }
516 | }
517 |
518 | /**
519 | * Expand comment if it is collapsed (on old reddit only).
520 | * @param {Element} innerEl An element inside the comment.
521 | */
522 | function expandComment(innerEl) {
523 | const collapsedComment = innerEl.closest(".collapsed");
524 | if (collapsedComment) {
525 | collapsedComment.classList.remove("collapsed");
526 | collapsedComment.classList.add("noncollapsed");
527 | }
528 | }
529 |
530 | /**
531 | * Handle show original event given the post to show content for.
532 | * @param {Element} linkEl The link element for showing the status.
533 | * @param {object} out The response from the API.
534 | * @param {object} post The archived data of the original comment/post.
535 | * @param {string} postId The ID of the original comment/post.
536 | * @param {Boolean} includeBody Whether or not to include the body of the original comment/post.
537 | */
538 | function handleShowOriginalEvent(linkEl, out, post, postId, includeBody) {
539 | // locate comment body
540 | const commentBodyElement = getPostBodyElement(postId);
541 | // check that comment was fetched and body element exists
542 | if (!commentBodyElement) {
543 | // the comment body element was not found
544 | linkEl.innerText = "body element not found";
545 | linkEl.title = "Please report this issue to the developer on GitHub.";
546 | logging.error("Body element not found:", out);
547 | } else if (typeof post?.body === "string") {
548 | // create new paragraph containing the body of the original comment
549 | showOriginalComment(commentBodyElement, "comment", post, includeBody);
550 | // remove loading status from comment
551 | linkEl.innerText = "";
552 | linkEl.removeAttribute("title");
553 | logging.info("Successfully loaded comment.");
554 | } else if (typeof post?.selftext === "string") {
555 | // check if result has selftext instead of body (it is a submission post)
556 | // create new paragraph containing the selftext of the original submission
557 | showOriginalComment(commentBodyElement, "post", post, includeBody);
558 | // remove loading status from post
559 | linkEl.innerText = "";
560 | linkEl.removeAttribute("title");
561 | logging.info("Successfully loaded post.");
562 | } else if (out?.data?.length === 0) {
563 | // data was returned empty
564 | linkEl.innerText = "not found";
565 | linkEl.title = "No matching results were found in the Pushshift archive.";
566 | logging.warn("No results:", out);
567 | } else if (out?.data?.length > 0) {
568 | // no matching comment/post was found in the data
569 | linkEl.innerText = "not found";
570 | linkEl.title = "The comment/post was not found in the Pushshift archive.";
571 | logging.warn("No matching post:", out);
572 | } else {
573 | // other issue occurred with displaying comment
574 | if (linkEl.innerText === "fetch failed") {
575 | const errorLink = linkEl.parentElement.querySelector(".error-link");
576 | const linkToPushshift = errorLink || document.createElement("a");
577 | linkToPushshift.target = "_blank";
578 | linkToPushshift.style = `text-decoration: underline;
579 | cursor: pointer;
580 | margin-left: 6px;
581 | font-style: normal;
582 | font-weight: bold;
583 | color: #e5766e;`;
584 | linkToPushshift.className = linkEl.className;
585 | linkToPushshift.classList.add("error-link");
586 | linkToPushshift.href = out?.detail
587 | ? "https://api.pushshift.io/signup"
588 | : "https://www.reddit.com/r/pushshift/";
589 | linkToPushshift.innerText = out?.detail || "CHECK r/PUSHSHIFT FOR MORE INFO";
590 | if (errorLink === null) {
591 | linkEl.parentElement.appendChild(linkToPushshift);
592 | }
593 | // unhide token container if token is missing or invalid
594 | if (out?.detail) {
595 | const tokenContainer = document.querySelector("#tokenContainer");
596 | tokenContainer.style.display = "block";
597 | storage.set("hideTokenContainer", "false");
598 | }
599 | }
600 | linkEl.innerText = "fetch failed";
601 | linkEl.title = "A Pushshift error occurred. Please check r/pushshift for updates.";
602 | logging.error("Fetch failed:", out);
603 | }
604 | }
605 |
606 | /**
607 | * Fetch alternative that runs fetch from the window context using a helper element.
608 | *
609 | * This is necessary because in Firefox the headers are not sent when running fetch from the addon context.
610 | *
611 | * @param {string} url The URL to fetch.
612 | * @param {object} options The options to pass to fetch.
613 | * @returns {Promise} The fetch promise.
614 | */
615 | function inlineFetch(url, options) {
616 | const outputContainer = document.createElement("div");
617 | outputContainer.id = "outputContainer" + Math.floor(Math.random() * Math.pow(10, 10));
618 | outputContainer.style.display = "none";
619 | document.body.appendChild(outputContainer);
620 | const responseContainer = document.createElement("div");
621 | responseContainer.id = "responseContainer" + Math.floor(Math.random() * Math.pow(10, 10));
622 | responseContainer.style.display = "none";
623 | document.body.appendChild(responseContainer);
624 | const temp = document.createElement("button");
625 | temp.setAttribute("type", "button");
626 | temp.setAttribute(
627 | "onclick",
628 | `fetch("${url}", ${JSON.stringify(options)})
629 | .then(r => {
630 | document.querySelector("#${responseContainer.id}").innerText = JSON.stringify({
631 | ok: r.ok,
632 | status: r.status,
633 | statusText: r.statusText,
634 | headers: Object.fromEntries(r.headers.entries()),
635 | });
636 | return r.text();
637 | })
638 | .then(t => document.querySelector("#${outputContainer.id}").innerText = t)`
639 | );
640 | temp.style.display = "none";
641 | document.body.appendChild(temp);
642 | temp.click();
643 | // wait for fetch to complete and return a promise
644 | return new Promise((resolve) => {
645 | const interval = setInterval(() => {
646 | if (outputContainer.innerText && responseContainer.innerText) {
647 | clearInterval(interval);
648 | const responseData = JSON.parse(responseContainer.innerText);
649 | const mockResponse = {
650 | text: () => outputContainer.innerText,
651 | json: () => JSON.parse(outputContainer.innerText),
652 | ok: responseData.ok,
653 | status: responseData.status,
654 | statusText: responseData.statusText,
655 | headers: {
656 | get: (header) => responseData.headers[header],
657 | },
658 | };
659 | resolve(mockResponse);
660 | outputContainer.remove();
661 | responseContainer.remove();
662 | temp.remove();
663 | }
664 | }, 100);
665 | });
666 | }
667 |
668 | /**
669 | * Create a link to view the original comment/post.
670 | * @param {Element} innerEl An element inside the comment or post to create a link for.
671 | */
672 | function createLink(innerEl) {
673 | // if there is already a link, don't create another unless the other was a show author link
674 | if (innerEl.parentElement.querySelector("a.showOriginal:not(.showAuthorOnly)")) {
675 | return;
676 | }
677 | // create link to "Show orginal" or "Show author"
678 | const showAuthor = innerEl.classList.contains("showAuthorOnly");
679 | const showLinkEl = document.createElement("a");
680 | showLinkEl.innerText = showAuthor ? "Show author" : "Show original";
681 | showLinkEl.className = innerEl.className + " showOriginal";
682 | showLinkEl.classList.remove("error");
683 | showLinkEl.style.textDecoration = "underline";
684 | showLinkEl.style.cursor = "pointer";
685 | showLinkEl.style.marginLeft = "6px";
686 | showLinkEl.title = "Click to show data from the original post or comment";
687 | innerEl.parentElement.appendChild(showLinkEl);
688 | innerEl.classList.add("match");
689 | // find id of selected comment or submission
690 | const postId = getPostId(showLinkEl);
691 | showLinkEl.alt = `View original post for ID ${postId}`;
692 | if (!postId) {
693 | showLinkEl.parentElement.removeChild(showLinkEl);
694 | }
695 | // click event
696 | showLinkEl.addEventListener(
697 | "click",
698 | async function () {
699 | // allow only 1 request at a time
700 | if (typeof currentLoading != "undefined" && currentLoading !== null) {
701 | return;
702 | }
703 | // create url for getting comment/post from pushshift api
704 | const URLs = [];
705 | const idURL = isInSubmission(this)
706 | ? `https://api.pushshift.io/reddit/search/submission/?ids=${postId}&fields=selftext,author,id,created_utc,permalink`
707 | : `https://api.pushshift.io/reddit/search/comment/?ids=${postId}&fields=body,author,id,link_id,created_utc,permalink`;
708 | URLs.push(idURL);
709 | // create url for getting author comments/posts from pushshift api
710 | const author = this.parentElement.querySelector("a[href*=user]")?.innerText;
711 | if (author) {
712 | const authorURL = isInSubmission(this)
713 | ? `https://api.pushshift.io/reddit/search/submission/?author=${author}&size=200&fields=selftext,author,id,created_utc,permalink`
714 | : `https://api.pushshift.io/reddit/search/comment/?author=${author}&size=200&fields=body,author,id,link_id,created_utc,permalink`;
715 | URLs.push(authorURL);
716 | }
717 | // if the author is unknown, check the parent post as an alternative instead
718 | else if (!isInSubmission(this)) {
719 | const parsedURL = parseURL();
720 | if (parsedURL.submissionId) {
721 | const parentURL = `https://api.pushshift.io/reddit/comment/search?q=*&link_id=${parsedURL.submissionId}&size=200&fields=body,author,id,link_id,created_utc,permalink`;
722 | URLs.push(parentURL);
723 | }
724 | }
725 |
726 | // set loading status
727 | currentLoading = this;
728 | this.innerText = "loading...";
729 | this.title = "Loading data from the original post or comment";
730 |
731 | logging.info(`Fetching from ${URLs.join(" and ")}`);
732 |
733 | const token = document.querySelector("#apiToken").value;
734 |
735 | // request from pushshift api
736 | await Promise.all(
737 | URLs.map((url) =>
738 | fetch(url, {
739 | method: "GET",
740 | headers: {
741 | "Content-Type": "application/json",
742 | "User-Agent": "Unedit and Undelete for Reddit",
743 | accept: "application/json",
744 | Authorization: `Bearer ${token}`,
745 | },
746 | })
747 | .then((response) => {
748 | if (!response.ok) {
749 | logging.error("Response not ok:", response);
750 | }
751 | try {
752 | return response.json();
753 | } catch (e) {
754 | throw Error(`Invalid JSON Response: ${response}`);
755 | }
756 | })
757 | .catch((error) => {
758 | logging.error("Error:", error);
759 | })
760 | )
761 | )
762 | .then((responses) => {
763 | responses.forEach((out) => {
764 | // locate the comment that was being loaded
765 | const loading = currentLoading;
766 | // exit if already found
767 | if (loading.innerText === "") {
768 | return;
769 | }
770 | const post = out?.data?.find((p) => p?.id === postId?.split("_").pop());
771 | logging.info("Response:", { author, id: postId, post, data: out?.data });
772 | const includeBody = !loading.classList.contains("showAuthorOnly");
773 | handleShowOriginalEvent(loading, out, post, postId, includeBody);
774 | });
775 | })
776 | .catch(function (err) {
777 | throw err;
778 | });
779 |
780 | // reset status
781 | currentLoading = null;
782 | },
783 | false
784 | );
785 | }
786 |
787 | /**
788 | * Convert unix timestamp in seconds to a relative time string (e.g. "2 hours ago").
789 | * @param {number} timestamp A unix timestamp in seconds.
790 | * @returns {string} A relative time string.
791 | */
792 | function getRelativeTime(timestamp) {
793 | const time = new Date(timestamp * 1000);
794 | const now = new Date();
795 | const seconds = Math.round((now.getTime() - time.getTime()) / 1000);
796 | const minutes = Math.round(seconds / 60);
797 | const hours = Math.round(minutes / 60);
798 | const days = Math.round(hours / 24);
799 | const months = Math.round(days / 30.5);
800 | const years = Math.round(days / 365);
801 | if (years > 0 && months >= 12) {
802 | return `${years} ${years === 1 ? "year" : "years"} ago`;
803 | }
804 | if (months > 0 && days >= 30) {
805 | return `${months} ${months === 1 ? "month" : "months"} ago`;
806 | }
807 | if (days > 0 && hours >= 24) {
808 | return `${days} ${days === 1 ? "day" : "days"} ago`;
809 | }
810 | if (hours > 0 && minutes >= 60) {
811 | return `${hours} ${hours === 1 ? "hour" : "hours"} ago`;
812 | }
813 | if (minutes > 0 && seconds >= 60) {
814 | return `${minutes} ${minutes === 1 ? "minute" : "minutes"} ago`;
815 | }
816 | return "just now";
817 | }
818 |
819 | /**
820 | * Locate comments and add links to each.
821 | */
822 | function findEditedComments() {
823 | // when function runs, cancel timeout
824 | if (scriptTimeout) {
825 | scriptTimeout = null;
826 | }
827 | // list elements to check for edited or deleted status
828 | let selectors = [],
829 | elementsToCheck = [],
830 | editedComments = [];
831 | // redesign
832 | if (!isOldReddit) {
833 | // check for edited/deleted comments and deleted submissions
834 | selectors = [
835 | ".Comment div:first-of-type span:not([data-text]):not(.found)", // Comments "edited..." or "Comment deleted/removed..."
836 | ".Post div div div:last-of-type div ~ div:last-of-type:not([data-text]):not(.found)", // Submissions "It doesn't appear in any feeds..." message
837 | ".Post > div:only-child > div:nth-of-type(5) > div:last-of-type > div:not([data-text]):only-child:not(.found)", // Submissions "Sorry, this post is no longer available." message
838 | ".Comment div.RichTextJSON-root > p:only-child:not([data-text]):not(.found)", // Comments "[unavailable]" message
839 | "div > div > svg:first-child + p:not(.found)", // "That Comment Is Missing" page
840 | ];
841 | elementsToCheck = Array.from(document.querySelectorAll(selectors.join(", ")));
842 | editedComments = elementsToCheck.filter(function (el) {
843 | el.classList.add("found");
844 | // we only care about the element if it has no children
845 | if (el.children.length) {
846 | return false;
847 | }
848 | // there are only specific phrases we care about in a P element
849 | if (
850 | el.tagName === "P" &&
851 | el.innerText !== "[unavailable]" &&
852 | el.innerText !== "[ Removed by Reddit ]" &&
853 | el.innerText !== "That Comment Is Missing"
854 | ) {
855 | return false;
856 | }
857 | // include "[unavailable]" comments (blocked by user) if from a deleted user
858 | const isUnavailable =
859 | el.innerText === "[unavailable]" &&
860 | el?.parentElement?.parentElement?.parentElement
861 | ?.querySelector("div")
862 | ?.innerText?.includes("[deleted]");
863 | const isEditedOrRemoved =
864 | el.innerText.substring(0, 6) === "edited" || // include edited comments
865 | el.innerText.substring(0, 15) === "Comment deleted" || // include comments deleted by user
866 | el.innerText.substring(0, 15) === "Comment removed" || // include comments removed by moderator
867 | el.innerText.substring(0, 30) === "It doesn't appear in any feeds" || // include deleted submissions
868 | el.innerText.substring(0, 23) === "Moderators remove posts" || // include submissions removed by moderators
869 | isUnavailable || // include unavailable comments (blocked by user)
870 | el.innerText === "[ Removed by Reddit ]" || // include comments removed by Reddit
871 | el.innerText === "That Comment Is Missing" || // include comments not found in comment tree
872 | el.innerText.substring(0, 29) === "Sorry, this post is no longer"; // include unavailable submissions (blocked by user)
873 | const isDeletedAuthor = el.innerText === "[deleted]"; // include comments from deleted users
874 | // if the element has a deleted author, make a link to only show the deleted author
875 | if (isDeletedAuthor) {
876 | el.classList.add("showAuthorOnly");
877 | }
878 | // keep element if it is edited or removed or if it has a deleted author
879 | return isEditedOrRemoved || isDeletedAuthor;
880 | });
881 | // Edited submissions found using the Reddit API
882 | editedSubmissions.forEach((submission) => {
883 | let found = false;
884 | const postId = submission.id;
885 | const editedAt = submission.edited;
886 | const deletedAuthor = submission.deletedAuthor;
887 | const deletedPost = submission.deletedPost;
888 | selectors = [
889 | `#t3_${postId} > div:first-of-type > div:nth-of-type(2) > div:first-of-type > div:first-of-type > span:first-of-type:not(.found)`, // Submission page
890 | `#t3_${postId} > div:first-of-type > div:nth-of-type(2) > div:first-of-type > div:first-of-type > div:first-of-type > div:first-of-type > span:first-of-type:not(.found)`, // Comment context page
891 | `#t3_${postId} > div:last-of-type[data-click-id] > div:first-of-type > div:first-of-type > div:first-of-type:not(.found)`, // Subreddit listing view
892 | `.Post.t3_${postId} > div:last-of-type[data-click-id] > div:first-of-type > div:nth-of-type(2) > div:not([data-adclicklocation]):first-of-type:not(.found)`, // Profile/home/classic listing view
893 | `.Post.t3_${postId} > div:first-of-type > div[data-click-id="background"] > div:first-of-type > div[data-click-id="body"] > div[data-adclicklocation="top_bar"]:not(.found)`, // Compact listing view
894 | `.Post.t3_${postId} > div:last-of-type[data-click-id] > div:first-of-type > div:nth-of-type(2) div[data-adclicklocation="top_bar"]:not(.found)`, // Profile/home listing view
895 | `.Post.t3_${postId}:not(.scrollerItem) > div:first-of-type > div:nth-of-type(2) > div:nth-of-type(2) > div:first-of-type > div:first-of-type:not(.found)`, // Preview popup
896 | ];
897 | Array.from(document.querySelectorAll(selectors.join(", "))).forEach((el) => {
898 | // add found class so that it won't be checked again in the future
899 | el.classList.add("found");
900 | // if this is the first time we've found this post, add it to the list of posts to add the link to
901 | if (!found) {
902 | found = true;
903 | editedComments.push(el);
904 | if (editedAt) {
905 | if (!el.parentElement.querySelector(".edited-date")) {
906 | // display when the post was edited
907 | const editedDateElement = document.createElement("span");
908 | editedDateElement.classList.add("edited-date");
909 | editedDateElement.style.fontStyle = "italic";
910 | editedDateElement.innerText = ` \u00b7 edited ${getRelativeTime(editedAt)}`; // middle-dot = \u00b7
911 | el.parentElement.appendChild(editedDateElement);
912 | }
913 | } else if (deletedAuthor && !deletedPost) {
914 | // if the post was not edited, make a link to only show the deleted author
915 | el.classList.add("showAuthorOnly");
916 | }
917 | }
918 | });
919 | });
920 | // If the url has changed, check for edited submissions again
921 | // This is an async fetch that will check for edited submissions again when it is done
922 | if (currentURL !== window.location.href) {
923 | logging.info(`URL changed from ${currentURL} to ${window.location.href}`);
924 | currentURL = window.location.href;
925 | checkForEditedSubmissions();
926 | }
927 | }
928 | // old Reddit and compact Reddit
929 | else {
930 | selectors = [
931 | ".entry p.tagline time:not(.found)", // Comment or Submission "last edited" timestamp
932 | ".entry p.tagline em:not(.found), .entry .tagline span:first-of-type:not(.flair):not(.found)", // Comment "[deleted]" author
933 | "div[data-url] p.tagline span:first-of-type:not(.flair):not(.found)", // Submission "[deleted]" author
934 | "div[data-url] .usertext-body em:not(.found), form.usertext em:not(.found)", // Submission "[removed]" body
935 | ".entry .usertext .usertext-body > div.md > p:only-child:not(.found)", // Comment "[unavailable]" body
936 | "p#noresults", // "there doesn't seem to be anything here" page
937 | ];
938 | elementsToCheck = Array.from(document.querySelectorAll(selectors.join(", ")));
939 | editedComments = elementsToCheck.filter(function (el) {
940 | el.classList.add("found");
941 | // The only messages we care about in a P element right now is "[unavailable]" or #noresults
942 | if (
943 | el.tagName === "P" &&
944 | el.innerText !== "[unavailable]" &&
945 | el.innerText !== "[ Removed by Reddit ]" &&
946 | el.id !== "noresults"
947 | ) {
948 | return false;
949 | }
950 | // include "[unavailable]" comments (blocked by user) if from a deleted user
951 | const isUnavailable =
952 | el.innerText === "[unavailable]" &&
953 | el?.closest(".entry").querySelector(".tagline").innerText.includes("[deleted]");
954 | const isEditedRemovedOrDeletedAuthor =
955 | el.title.substring(0, 11) === "last edited" || // include edited comments or submissions
956 | el.innerText === "[deleted]" || // include comments or submissions deleted by user
957 | el.innerText === "[removed]" || // include comments or submissions removed by moderator
958 | el.innerText === "[ Removed by Reddit ]" || // include comments or submissions removed by Reddit
959 | el.id === "noresults" || // include "there doesn't seem to be anything here" page
960 | isUnavailable; // include unavailable submissions (blocked by user)
961 | // if the element is a deleted author and not edited or removed, only show the deleted author
962 | if (
963 | el.innerText === "[deleted]" &&
964 | el.tagName.toUpperCase() === "SPAN" && // tag name is span (not em as it appears for deleted comments)
965 | ["[deleted]", "[removed]"].indexOf(el.closest(".entry")?.querySelector(".md")?.innerText) === -1 // content of post is not deleted or removed
966 | ) {
967 | el.classList.add("showAuthorOnly");
968 | }
969 | // keep element if it is edited or removed or if it has a deleted author
970 | return isEditedRemovedOrDeletedAuthor;
971 | });
972 | }
973 | // create links
974 | editedComments.forEach(function (el) {
975 | // for removed submissions, add the link to an element in the tagline instead of the body
976 | if (el.closest(".usertext-body") && el.innerText === "[removed]") {
977 | el = el.closest(".entry")?.querySelector("p.tagline span:first-of-type") || el;
978 | }
979 | createLink(el);
980 | });
981 | }
982 |
983 | /**
984 | * If the script timeout is not already set, set it and
985 | * run the findEditedComments in a second, otherwise do nothing.
986 | */
987 | function waitAndFindEditedComments() {
988 | if (!scriptTimeout) {
989 | scriptTimeout = setTimeout(findEditedComments, 1000);
990 | }
991 | }
992 |
993 | /**
994 | * Check for edited submissions using the Reddit JSON API.
995 | *
996 | * Since the Reddit Redesign website does not show if a submission was edited,
997 | * we will check the data in the Reddit JSON API for the information.
998 | */
999 | function checkForEditedSubmissions() {
1000 | // don't need to check if we're not on a submission page or list view
1001 | if (!document.querySelector(".Post, .ListingLayout-backgroundContainer")) {
1002 | return;
1003 | }
1004 | // append .json to the page URL but before the ?
1005 | const [url, query] = window.location.href.split("?");
1006 | const jsonUrl = `${url}.json` + (query ? `?${query}` : "");
1007 | logging.info(`Fetching additional info from ${jsonUrl}`);
1008 | fetch(jsonUrl, {
1009 | method: "GET",
1010 | headers: {
1011 | "Content-Type": "application/json",
1012 | "User-Agent": "Unedit and Undelete for Reddit",
1013 | },
1014 | })
1015 | .then(function (response) {
1016 | if (!response.ok) {
1017 | throw new Error(`${response.status} ${response.statusText}`);
1018 | }
1019 | return response.json();
1020 | })
1021 | .then(function (data) {
1022 | logging.info("Response:", data);
1023 | const out = data?.length ? data[0] : data;
1024 | const children = out?.data?.children;
1025 | if (children) {
1026 | editedSubmissions = children
1027 | .filter(function (post) {
1028 | return post.kind === "t3" && (post.data.edited || post.data.author === "[deleted]");
1029 | })
1030 | .map(function (post) {
1031 | return {
1032 | id: post.data.id,
1033 | edited: post.data.edited,
1034 | deletedAuthor: post.data.author === "[deleted]",
1035 | deletedPost: post.data.selftext === "[deleted]" || post.data.selftext === "[removed]",
1036 | };
1037 | });
1038 | logging.info("Edited submissions:", editedSubmissions);
1039 | setTimeout(findEditedComments, 1000);
1040 | }
1041 | })
1042 | .catch(function (error) {
1043 | logging.error(`Error fetching additional info from ${jsonUrl}`, error);
1044 | });
1045 | }
1046 |
1047 | // check for new comments when you scroll
1048 | window.addEventListener("scroll", waitAndFindEditedComments, true);
1049 |
1050 | // check for new comments when you click
1051 | document.body.addEventListener("click", waitAndFindEditedComments, true);
1052 |
1053 | // add additional styling, find edited comments, and set old reddit status on page load
1054 | function init() {
1055 | // output the version number to the console
1056 | logging.info(`Unedit and Undelete for Reddit v${VERSION}`);
1057 | // determine if reddit is old or redesign
1058 | isOldReddit = /old\.reddit/.test(window.location.href) || !!document.querySelector("#header-img");
1059 | isCompact = document.querySelector("#header-img-a")?.href?.endsWith(".compact") || false;
1060 | // upgrade insecure requests
1061 | document.head.insertAdjacentHTML(
1062 | "beforeend",
1063 | ``
1064 | );
1065 | // Reddit redesign
1066 | if (!isOldReddit) {
1067 | // fix styling of created paragraphs in new reddit
1068 | document.head.insertAdjacentHTML(
1069 | "beforeend",
1070 | ``
1188 | );
1189 | // listen for spoilertext in original body to be revealed
1190 | window.addEventListener(
1191 | "click",
1192 | function (e) {
1193 | /**
1194 | * @type {HTMLSpanElement}
1195 | */
1196 | const spoiler = e.target.closest("span.md-spoiler-text");
1197 | if (spoiler) {
1198 | spoiler.classList.add("revealed");
1199 | spoiler.removeAttribute("title");
1200 | spoiler.style.cursor = "auto";
1201 | }
1202 | },
1203 | false
1204 | );
1205 | // check for edited submissions
1206 | checkForEditedSubmissions();
1207 | }
1208 | // Old Reddit
1209 | else {
1210 | // fix styling of created paragraphs in old reddit
1211 | document.head.insertAdjacentHTML(
1212 | "beforeend",
1213 | ``
1263 | );
1264 | }
1265 | // find edited comments
1266 | findEditedComments();
1267 |
1268 | // create an input field in the bottom right corner of the screen for the api token
1269 | document.head.insertAdjacentHTML(
1270 | "beforeend",
1271 | ``
1315 | );
1316 | const tokenInput = document.createElement("input");
1317 | tokenInput.type = "text";
1318 | tokenInput.id = "apiToken";
1319 | tokenInput.placeholder = "Pushshift API Token";
1320 | // if there is a token saved in local storage, use it
1321 | storage.get("apiToken").then((token) => {
1322 | if (token) {
1323 | tokenInput.value = token;
1324 | }
1325 | });
1326 | const requestTokenLink = document.createElement("a");
1327 | requestTokenLink.href = "https://api.pushshift.io/signup";
1328 | requestTokenLink.target = "_blank";
1329 | requestTokenLink.rel = "noopener noreferrer";
1330 | requestTokenLink.textContent = "Request Token";
1331 | requestTokenLink.id = "requestTokenLink";
1332 | const saveButton = document.createElement("button");
1333 | saveButton.textContent = "Save";
1334 | saveButton.id = "saveButton";
1335 | saveButton.addEventListener("click", function () {
1336 | // save in local storage
1337 | storage.set("apiToken", tokenInput.value);
1338 | });
1339 | tokenInput.addEventListener("keydown", function (e) {
1340 | if (e.key === "Enter") {
1341 | saveButton.click();
1342 | }
1343 | });
1344 | const closeButton = document.createElement("button");
1345 | closeButton.textContent = "\u00D7"; // times symbol
1346 | closeButton.id = "closeButton";
1347 | const tokenContainer = document.createElement("div");
1348 | tokenContainer.id = "tokenContainer";
1349 | tokenContainer.appendChild(tokenInput);
1350 | tokenContainer.appendChild(saveButton);
1351 | tokenContainer.appendChild(requestTokenLink);
1352 | tokenContainer.appendChild(closeButton);
1353 | closeButton.addEventListener("click", function () {
1354 | // set the token container to display none
1355 | tokenContainer.style.display = "none";
1356 | // save preference in local storage
1357 | storage.set("hideTokenContainer", "true");
1358 | });
1359 | // if the user has hidden the token container before, hide it again
1360 | storage.get("hideTokenContainer").then((hideTokenContainer) => {
1361 | if (hideTokenContainer === "true") {
1362 | tokenContainer.style.display = "none";
1363 | }
1364 | });
1365 | document.body.appendChild(tokenContainer);
1366 |
1367 | // switch from fetch to inlineFetch if browser is Firefox
1368 | if (navigator.userAgent.includes("Firefox")) {
1369 | fetch = inlineFetch;
1370 | }
1371 | }
1372 |
1373 | // if the window is loaded, run init(), otherwise wait for it to load
1374 | if (document.readyState === "complete") {
1375 | init();
1376 | } else {
1377 | window.addEventListener("load", init, false);
1378 | }
1379 | })();
1380 |
--------------------------------------------------------------------------------