LynxChan Extended Minus Minus

LynxChan Extended with even more features

  1. // ==UserScript==
  2. // @name LynxChan Extended Minus Minus
  3. // @namespace https://rentry.org/8chanMinusMinus
  4. // @version 2.4.6
  5. // @description LynxChan Extended with even more features
  6. // @author SaddestPanda & Dandelion & /gfg/
  7. // @license UNLICENSE
  8. // @match *://8chan.moe/*
  9. // @match https://dev.8ch.moe/*
  10. // @match *://8chan.se/*
  11. // @match *://8chan.cc/*
  12. // @match *://alephchvkipd2houttjirmgivro5pxullvcgm4c47ptm7mhubbja6kad.onion/*
  13. // @grant GM.getValue
  14. // @grant GM.setValue
  15. // @grant GM.deleteValue
  16. // @grant GM.registerMenuCommand
  17. // @grant GM.listValues
  18. // @run-at document-start
  19. // ==/UserScript==
  20.  
  21. //TODO LATER MAYBE: combine all CSS into one <style> and use classes on html or body instead.
  22. (async function () {
  23. "use strict";
  24.  
  25. const REGEX_THREAD = /\/res|last\//;
  26. const SETTINGS_DEFINITIONS = {
  27. firstRun:{
  28. default:true,
  29. hidden:true,
  30. desc:"You shouldn't be able to see this setting! (firstRun)"
  31. },
  32. addKeyboardHandlers:{
  33. default:true,
  34. desc:"Add keyboard Ctrl+ hotkeys to the quick reply box (Disable this for 8chanSS compatibility)"
  35. },
  36. showScrollbarMarkers:{
  37. default:true,
  38. type:"checkbox_with_colors",
  39. desc:"Show your posts and replies on the scrollbar",
  40. color1Default:"#0092ff",
  41. color1Desc:"<b>Your marker:</b>",
  42. color2Default:"#a8d8f8",
  43. color2Desc:"<b>Reply marker:</b>"
  44. },
  45. spoilerImageType:{
  46. default:"off",
  47. desc:"Override how the spoiler thumbnail looks:",
  48. type:"radio",
  49. options:{
  50. off:"Don't change the thumbnail.",
  51. reveal:"Reveal spoilers <span class='altText lineBefore'>(Previously spoilered images will have a red border around them indicating that they're spoilers.)</span>",
  52. reveal_blur:"Change to a blurred thumbnail <span class='altText lineBefore'>(Unblurred when you hover your mouse over.)</span>",
  53. kachina:"Makes the spoiler image Kachina from Genshin Impact.",
  54. thread:`<b>Use <b style="color: var(--link-color);">"ThreadSpoiler.jpg"</b> from the current thread <span class="altText lineBefore">(first posted jpg, png or webp image with that filename)</span></b>`,
  55. threadAlt:`same as above with the filename <b style="color: var(--link-color);">"ThreadSpoilerAlt.jpg"</b> <span class="altText lineBefore">(jpg, png or webp; uses ThreadSpoiler.jpg until this is found)</span>`,
  56. //test:`[TEST OPTION] Set custom spoiler thumb per-thread (For /gacha/ only!)`
  57. },
  58. nonewline:true
  59. },
  60. overrideBoardSpoilerImage: {
  61. default:true,
  62. parent:"spoilerImageType",
  63. //Not implemented yet
  64. //depends: function() {return settings.spoilerImageType != "off"},
  65. desc:"Also override board's custom thumbnail image <span class='altText lineBefore'>(for example, /v/'s spoiler thumbnail is an image of a monitor with a ? inside it)</span>"
  66. },
  67. revealSpoilerText:{
  68. default:"off",
  69. desc:"Reveal the spoiler text. Or make it into madoka runes.",
  70. type:"radio",
  71. options:{
  72. off:"Don't reveal spoilers.",
  73. on:"Spoilers will be always be shown by turning the text white.",
  74. madoka:`Spoilers will turn into madoka runes. Please install <a href="https://www.dropbox.com/s/n6ys414nviitr9y/MadokaRunes-2.0.ttf"><u>MadokaRunes.ttf</u></a> for it to show up properly.`
  75. }
  76. },
  77. markPostEdge:{
  78. default:true,
  79. type:"checkbox_with_colors",
  80. desc:"<span class='boldText'>Style:</span> Mark your posts and replies <span class='altText'>(with a left border)</span>",
  81. color1Default:"#4BB2FF",
  82. color1Desc:"<b>Your border:</b>",
  83. color2Default:"#0066ff",
  84. color2Desc:"<b>Reply border:</b>",
  85. nonewline:true
  86. },
  87. markYouText:{
  88. default:true,
  89. type:"checkbox_with_colors",
  90. desc:"<span class='boldText'>Style:</span> Color your name and (You) links",
  91. color1Default:"#ff2222",
  92. color1Desc:"<b>Color:</b>",
  93. nonewline:true
  94. },
  95. compactPosts:{
  96. default:true,
  97. desc:"<span class='boldText'>Style:</span> Make thumbnails and posts more compact",
  98. nonewline:true
  99. },
  100. showStubs:{
  101. default:true,
  102. desc:"<span class='boldText'>Style:</span> Show post stubs when filtering",
  103. nonewline:true
  104. },
  105. //I swear this used to be a built in option on 8chan
  106. halfchanGreentexts:{
  107. default:false,
  108. desc:"<span class='boldText'>Style:</span> Make the greentext brighter like 4chan",
  109. nonewline:true
  110. },
  111. fixPostConfirmStyling:{
  112. default:true,
  113. desc:"<span class='boldText'>Style:</span> Show a loading icon in the post confirmation &amp; refresh buttons"
  114. },
  115. glowFirstPostByID:{
  116. default:true,
  117. type:"checkbox_with_colors",
  118. desc:"Mark new/unique posters by adding a glow effect to their ID",
  119. color1Default:"#26bf47",
  120. color1Desc:"<b>Glow color:</b>",
  121. nonewline:true
  122. },
  123. showPostIndex:{
  124. default:true,
  125. type:"checkbox_with_colors",
  126. desc:"Show the current index of a post on the thread. <span class='altText'>(OP: 1, first post: 2 etc.)</span>",
  127. color1Default:"#7b3bcc",
  128. color1Desc:"<b>Index color:</b>",
  129. nonewline:true
  130. },
  131. showWatcherOnLoad: {
  132. default:false,
  133. desc:'Show the "Watched Threads" popup on page load',
  134. nonewline:true
  135. },
  136. showVideoIcons: {
  137. default:true,
  138. desc:"Distinguish videos, gifs and audio with an icon before the filename"
  139. },
  140. preserveQuickReply:{
  141. default:false,
  142. desc:"Preserve the quick reply text when closing the box or refreshing the page",
  143. nonewline:false
  144. },
  145. reverseSearchOptions:{
  146. default:{
  147. pixiv:true,
  148. booru:true,
  149. saucenao:true
  150. },
  151. desc:"Reverse image search buttons to show:",
  152. type:"checkbox_multiple_dict", //Maybe "multiple_array" or "bitfield" types allowed in the future
  153. options:{
  154. pixiv:"Pixiv <span class='altText lineBefore'>Shown if the filename matches an image downloaded from Pixiv</span>",
  155. booru:"Gelbooru / Danbooru / Safebooru <span class='altText lineBefore'>Shown if the filename contains an md5 hash</span>",
  156. saucenao:"Saucenao <span class='altText lineBefore'>Always shown, uses JS to download and reupload the image to saucenao</span>"
  157. },
  158. nonewline:true
  159. },
  160. reverseSearchBooruSite:{
  161. desc:"Booru to link if the above option is enabled",
  162. type:"dropdown",
  163. default:"gelbooru",
  164. choices:{
  165. "gelbooru":"https://gelbooru.com/index.php?page=post&s=list&tags=md5%3a",
  166. "danbooru":"https://danbooru.donmai.us/posts?tags=md5%3a",
  167. "safebooru":"https://safebooru.org/index.php?page=post&s=list&tags=md5%3a"
  168. }
  169. },
  170. /*writeCookies:{
  171. desc:"(DEVELOPER SETTING) Writes a cookie that expires in 1 week every time you load the page.",
  172. type:"textinput",
  173. category:"debug",
  174. value1Desc:"Name",
  175. value1Default:"",
  176. value2Desc:"Value",
  177. value2Default:"1"
  178. }*/
  179. filterByImageHash:{
  180. desc:"Filter images by their 8chan assigned hash",
  181. type:"textinput",
  182. category:"filter",
  183. default:"#Example: Filter an image\n#/52d7d2f07c1ab479ab8294b4482533c4d6dde2638d60e6f5864ee5fb844bc399/\n#Example: Filter image and then filter the poster's ID\n#/9ffe2e437a229e0e5bd93368df7eacfd5dbb6bc4ac170378e852411c3f437851/;poster;"
  184. },
  185. filterByImageName:{
  186. desc:"Filter images by their filename",
  187. type:"textinput",
  188. category:"filter",
  189. default:"#Example: Filter all images containing the word 'doro'\n#/doro/i;\n#Example: Filter all images with a randomized filename\n#/[0-9a-f]{64}/"
  190. }
  191. }
  192.  
  193. const settingsNames = Object.keys(SETTINGS_DEFINITIONS);
  194.  
  195. //Collect all color fields for checkbox_with_colors settings
  196. //In the userscript storage they look like settingName_color1 etc.
  197. const colorSettingKeys = [];
  198. settingsNames.forEach(key => {
  199. const def = SETTINGS_DEFINITIONS[key];
  200. if (def.type === "checkbox_with_colors") {
  201. Object.keys(def).forEach(k => {
  202. const match = k.match(/^color(\d+)Default$/);
  203. if (match) {
  204. colorSettingKeys.push(`${key}_color${match[1]}`);
  205. }
  206. });
  207. }
  208. });
  209.  
  210. //Compose all keys to load: main settings + color fields
  211. const allSettingKeys = [...settingsNames, ...colorSettingKeys];
  212.  
  213. //For each color field, get its default from the definition
  214. function getDefaultForKey(key) {
  215. const colorMatch = key.match(/^(.+)_color(\d+)$/);
  216. if (colorMatch) {
  217. const [_, base, idx] = colorMatch;
  218. const def = SETTINGS_DEFINITIONS[base];
  219. //Return color setting default like color1Default
  220. return def && def[`color${idx}Default`] ? def[`color${idx}Default`] : undefined;
  221. }
  222. //Return regular setting
  223. return SETTINGS_DEFINITIONS[key]?.default;
  224. }
  225.  
  226. const allSettingDefaults = allSettingKeys.map(getDefaultForKey);
  227. const allSettingValues = await Promise.all(allSettingKeys.map((key, i) => GM.getValue(key, allSettingDefaults[i])));
  228. //To access a setting, do settings[name_of_setting]
  229. const settings = Object.fromEntries(allSettingKeys.map((key, i) => [key, allSettingValues[i]]));
  230.  
  231. //This might be a case of overcomplicating. Okay, it definitely is.
  232. // TODO: Change this to a function that scans the keys in 'settings'
  233. // and if they start with 'filter' and use type 'textinput',
  234. // convert to an object
  235. // Because we only save the text value contained in 'settings' anyways
  236. // So if someone adds a new filter it would get pushed to 'settings'
  237. // the object here is effectively read only
  238. // class FilteringManager {
  239. // constructor(keyValueObject)
  240. // {
  241. // this.filters = {}
  242. // this.deserializeFilters(keyValueObject)
  243. // }
  244.  
  245. // deserializeFilters(keyValueObject) {
  246.  
  247. // Object.keys(keyValueObject).forEach(k => {
  248. // this.filters[k] = []
  249. // let regexLines = keyValueObject[k].split("\n");
  250. // for(let i = 0, line; line = regexLines[i]; i++) {
  251. // if (line.startsWith("#"))
  252. // continue;
  253. // const filterAndOptions = line.split(";")
  254. // const newFilterObject = {
  255. // filter: filterAndOptions[0]
  256. // }
  257. // for (let j = 1, option; option = filterAndOptions[j]; j++) {
  258. // const optionAndParams = option.split(":",2)
  259. // const optionName = optionAndParams[0]
  260. // if (optionAndParams.length > 1)
  261. // newFilterObject[optionName] = optionAndParams[1]
  262. // else
  263. // newFilterObject[optionName] = true;
  264. // }
  265.  
  266. // this.filters[k].push(newFilterObject);
  267. // }
  268. // })
  269. // console.log(this.filters)
  270. // }
  271.  
  272. // serializeFilters() {
  273.  
  274. // }
  275. // }
  276. // const CustomFilters = new FilteringManager({
  277. // 'ImageHash':settings.filterByImageHash,
  278. // 'ImageName':settings.filterByImageName
  279. // });
  280. function deserializeFilters() {
  281.  
  282. let keyValueObject = {};
  283. for (let i = 0; i < settingsNames.length; i++) {
  284. const name = settingsNames[i]
  285. if (name.startsWith("filterBy") && SETTINGS_DEFINITIONS[name]['category']=="filter") {
  286. keyValueObject[name.slice(8)] = settings[name]
  287. }
  288. }
  289. console.log(keyValueObject);
  290.  
  291.  
  292. function parseRegexString(str) {
  293. const match = str.match(/^\/(.+)\/([a-z]*)$/i);
  294. if (!match) throw new Error("Invalid regex string format "+str);
  295.  
  296. const [, pattern, flags] = match;
  297. return new RegExp(pattern, flags);
  298. }
  299.  
  300. let filters = {}
  301.  
  302. Object.keys(keyValueObject).forEach(k => {
  303. filters[k] = {}
  304.  
  305. let regexLines = keyValueObject[k].split("\n");
  306. for(let i = 0, line; line = regexLines[i]; i++) {
  307. if (line.startsWith("#"))
  308. continue;
  309. try {
  310.  
  311. const filterAndOptions = line.split(";")
  312. const newFilterObject = {
  313. regex: parseRegexString(filterAndOptions[0])
  314. }
  315. //console.log(filterAndOptions);
  316. for (let j = 1, option; option = filterAndOptions[j]; j++) {
  317. const optionAndParams = option.split(":",2)
  318. const optionName = optionAndParams[0]
  319. //console.log(optionAndParams)
  320. if (optionAndParams.length > 1)
  321. newFilterObject[optionName] = optionAndParams[1]
  322. else
  323. newFilterObject[optionName] = true;
  324. }
  325.  
  326. filters[k][filterAndOptions[0]] = newFilterObject;
  327. } catch (e) {
  328. console.error(e)
  329. }
  330. }
  331. })
  332. console.log(filters)
  333. return filters;
  334. }
  335. const CustomFilters = deserializeFilters();
  336.  
  337. function addMyStyle(newID, newStyle) {
  338. let myStyle = document.createElement("style");
  339. //myStyle.type = 'text/css';
  340. myStyle.id = newID;
  341. myStyle.textContent = newStyle;
  342. document.head.appendChild(myStyle);
  343. }
  344.  
  345. function waitForDom(callback) {
  346. if (document.readyState === "loading") {
  347. //Loading hasn't finished yet. Wait for the inital document to load and start.
  348. document.addEventListener("DOMContentLoaded", callback);
  349. } else {
  350. //Document has already loaded. Start.
  351. callback();
  352. }
  353. }
  354.  
  355. if (document?.head) {
  356. runASAP();
  357. } else {
  358. //On some environments document.head doesn't exist yet?
  359. waitForDom(runASAP);
  360. }
  361.  
  362. async function runASAP() {
  363. // Migrations can be removed in a few weeks
  364. // Migrations are disabled now. Keeping the code for potential future migrations
  365.  
  366. // // Migrate old useExtraStylingFixes setting if present
  367. // const oldStyling = await GM.getValue("useExtraStylingFixes", undefined);
  368. // if (typeof oldStyling !== "undefined") {
  369. // // If oldStyling is false, set both new options to false
  370. // if (oldStyling === false) {
  371. // settings.markPostEdge = false;
  372. // settings.compactPosts = false;
  373. // await GM.setValue("markPostEdge", false);
  374. // await GM.setValue("compactPosts", false);
  375. // }
  376. // // Remove the old setting
  377. // await GM.deleteValue("useExtraStylingFixes");
  378. // }
  379.  
  380. localStorage.setItem("qrClearOnClose",!settings.preserveQuickReply)
  381.  
  382. //Secret tip for anyone manually editing colors:
  383. //if you edit the saved value in your userscript manager's settings database manually, you can use semi-transparent colors for the color pickers (until you click save on the settings menu).
  384. //or easier: just copy the relevant part of the css and paste it to the css box in the website settings. Add !important if you want to force it like: color: red !important;
  385.  
  386. //Apply all the styles as soon as possible
  387. if (settings.compactPosts) {
  388. addMyStyle("lynx-compact-posts", `
  389. /* smaller thumbnails & image paddings */
  390. body .uploadCell img:not(.imgExpanded) {
  391. max-width: 160px;
  392. max-height: 125px;
  393. object-fit: contain;
  394. height: auto;
  395. width: auto;
  396. margin-right: 0em;
  397. margin-bottom: 0em;
  398. }
  399.  
  400. .uploadCell {
  401. margin-bottom: 0.45em;
  402. }
  403.  
  404. .uploadCell .imgLink {
  405. margin-right: 1em;
  406. }
  407.  
  408. /* smaller post spacing (not too much) */
  409. .divMessage {
  410. margin: .8em .8em .5em 3em;
  411. }
  412.  
  413. /* file details: reduce paddings and icon sizes */
  414. .uploadDetails {
  415. & > * {
  416. vertical-align: top;
  417. font-size: 95%;
  418. }
  419. & > .dimensionLabel {
  420. margin-right: 0.3ch;
  421. }
  422. .coloredIcon {
  423. font-size: 90%;
  424. }
  425. & > a.nameLink {
  426. margin-right: -2.5px;
  427. }
  428. & > span.hideFileButton {
  429. margin-right: -4px;
  430. }
  431. }
  432.  
  433. /* This thing adds an unnecessary line break (only on chrome) */
  434. .uploadCell > details > summary + br {
  435. display: none;
  436. }
  437.  
  438. /* Contain expanded images to the page */
  439. .imgExpanded {
  440. max-height: 100vh;
  441. object-fit: contain;
  442. }
  443.  
  444. `);
  445. }
  446.  
  447. const markerColor1 = settings.showScrollbarMarkers_color1 || SETTINGS_DEFINITIONS.showScrollbarMarkers.color1Default;
  448. const markerColor2 = settings.showScrollbarMarkers_color2 || SETTINGS_DEFINITIONS.showScrollbarMarkers.color2Default;
  449. const indexColor = settings.showPostIndex_color1 || SETTINGS_DEFINITIONS.showPostIndex.color1Default;
  450. const glowColor = settings.glowFirstPostByID_color1 || SETTINGS_DEFINITIONS.glowFirstPostByID.color1Default;
  451. addMyStyle("lynx-extended-css", `
  452. :root {
  453. --showScrollbarMarkers_color1: ${markerColor1};
  454. --showScrollbarMarkers_color2: ${markerColor2};
  455. --showPostIndex_color1: ${indexColor};
  456. --glowFirstPostByID_color1: ${glowColor};
  457. /*--settings-header-height: 40px;*/
  458. }
  459.  
  460. /* Booru links */
  461. /* For multiple uploads the button is below the image, for single upload it's next to the filename */
  462. /* single upload buttons can also be moved below the image by setting the innerPost as relative. */
  463. .lynxReverseImageSearch a:hover {
  464. text-decoration: underline;
  465. }
  466. .lynxReverseImageSearch > a {
  467. margin: 0 4px 0 1px;
  468.  
  469. &.fetch-awaiting::after {
  470. content: attr(data-booruname)" (please wait...)" !important;
  471. }
  472.  
  473. &.fetch-failed::after {
  474. content: attr(data-booruname)" (failed!)" !important;
  475. }
  476.  
  477. & > svg {
  478. margin-block: -2.5px;
  479. height: 1em;
  480. width: 1em;
  481. }
  482. }
  483.  
  484. .panelUploads:not(.multipleUploads) .lynxReverseImageSearch > a::after, lynxReverseImageSearch.showSearchNames > a::after {
  485. content: attr(data-booruname);
  486. }
  487.  
  488. .multipleUploads .uploadCell:has(.lynxReverseImageSearch) {
  489. position: relative;
  490. }
  491.  
  492. .multipleUploads .lynxReverseImageSearch {
  493. position: absolute;
  494. bottom: 0;
  495. left: 0;
  496. z-index: 2; /* Fixes the button not being clickable if you have spoiler thumbs disabled */
  497.  
  498. & a {
  499. font-size: 0.9em;
  500. filter: drop-shadow(0px 0px 1px black) drop-shadow(0px 0px 1px var(--contrast-color)) drop-shadow(0px 0px 1px var(--contrast-color));
  501. &:hover,
  502. &:active {
  503. background: var(--contrast-color);
  504. opacity: 0.9;
  505. max-width: 999px;
  506. &::after {
  507. content: attr(data-booruname);
  508. }
  509. }
  510. }
  511. }
  512.  
  513. /* video icons for filenames */
  514. a.originalNameLink.lynx-video::before {
  515. content: "\\e0a9";
  516. font-family: "Icons"; /* open-iconic font from the page */
  517. font-size: 90%;
  518. margin-right: 2px;
  519. }
  520.  
  521. /* Scrollbar you and reply markers */
  522. .marker-container {
  523. position: fixed;
  524. top: 8px;
  525. right: 0px;
  526. width: 10px;
  527. height: calc(100vh - 16px);
  528. z-index: 11000;
  529. pointer-events: none;
  530. }
  531.  
  532. .marker {
  533. position: absolute;
  534. width: 100%;
  535. height: 7px;
  536. background: var(--showScrollbarMarkers_color1);
  537. cursor: pointer;
  538. pointer-events: auto;
  539. border-radius: 40% 0 0 40%;
  540. z-index: 5;
  541. filter: drop-shadow(0px 0px 1px #000000BA);
  542. }
  543.  
  544. .marker.alt {
  545. background: var(--showScrollbarMarkers_color2);
  546. z-index: 2;
  547. }
  548.  
  549. .postNum.index {
  550. color: var(--showPostIndex_color1);
  551. font-weight: bold;
  552. }
  553.  
  554. .labelId.glows {
  555. box-shadow: 0 0 15px var(--glowFirstPostByID_color1);
  556. }
  557.  
  558. /* TODO LATER: switched this container to flexbox. Cleanup commented lines later if everything is good */
  559. #lynxExtendedMenu {
  560. display: flex;
  561. flex-direction: column;
  562. position: fixed;
  563. top: 15px;
  564. left: 50%;
  565. transform: TranslateX(-50%);
  566. padding: 10px;
  567. z-index: 10000;
  568. font-family: Arial, sans-serif;
  569. font-size: 14px;
  570. box-shadow: 0 2px 5px rgba(0, 0, 0, 0.5);
  571. background: var(--contrast-color);
  572. color: var(--text-color);
  573. border: 1px solid #737373;
  574. border-radius: 4px;
  575. height: 90vh;
  576. max-height: 90vh;
  577. width: 50vw; /* fixed width for (for > 1080px viewport width) */
  578.  
  579. & .altText {
  580. opacity: 0.8;
  581. font-size: 0.9em;
  582.  
  583. &.lineBefore::before {
  584. content: "—— ";
  585. }
  586. }
  587.  
  588. & .boldText {
  589. color: var(--link-color);
  590. font-weight: bold;
  591. }
  592.  
  593. & .colorLabel {
  594. padding: 3px 2px 3px 4px;
  595. border-radius: 8px;
  596. box-shadow: 0px 0px 1px currentColor;
  597. margin-left: 0.5em;
  598. }
  599.  
  600. & input[type="color"] {
  601. width: 40px;
  602. height: 20px;
  603. padding: 1px;
  604. transform: translate(0, 2px);
  605. }
  606.  
  607. & button {
  608. padding: 10px 20px;
  609. margin-right: 4px;
  610. margin-bottom: 0;
  611. filter: contrast(115%) brightness(110%);
  612. &:hover {
  613. filter: brightness(130%);
  614. }
  615. }
  616.  
  617. .page1 {
  618. line-height: 0.9em;
  619. }
  620.  
  621. textarea {
  622. font-family: monospace;
  623. width: 98%;
  624. height: 100%;
  625. resize: vertical;
  626. color: var(--text-color);
  627. background-color: var(--background-color);
  628. overflow: auto;
  629. white-space: pre; /* prevent text wrapping. filters are parsed line-by-line */
  630. }
  631.  
  632. .tab {
  633. font-weight: bold;
  634. padding: 2px 4px;
  635. border-radius: 5px;
  636. border: 1px solid transparent;
  637.  
  638. &.active {
  639. border-color: var(--link-color);
  640. }
  641. }
  642. }
  643.  
  644. #lynxExtendedMenu > .settings-header {
  645. /*height: var(--settings-header-height);*/
  646. padding-top: 2px;
  647. padding-bottom: 5px;
  648. }
  649. #lynxExtendedMenu > .settings-content {
  650. overflow-y: auto;
  651. /*height: calc(100% - var(--settings-header-height))*/
  652. /* height: 88%; */
  653. }
  654. #lynxExtendedMenu > .settings-footer {
  655. margin-top: auto;
  656. /* height: auto; */
  657. /*position: absolute;
  658. bottom: 1px;*/
  659. }
  660.  
  661. @media screen and (max-width: 1080px) {
  662. #lynxExtendedMenu{
  663. right:0;
  664. width:90%;
  665. line-height: 1em;
  666. }
  667. }
  668.  
  669. .lynxExtendedButton::before {
  670. content: "\\e0da";
  671. }
  672.  
  673. `);
  674.  
  675. if (settings.markPostEdge) {
  676. const color1 = settings.markPostEdge_color1 || SETTINGS_DEFINITIONS.markPostEdge.color1Default;
  677. const color2 = settings.markPostEdge_color2 || SETTINGS_DEFINITIONS.markPostEdge.color2Default;
  678. addMyStyle("lynx-mark-posts", `
  679. /*
  680. README:
  681. Mark your posts and replies with a left border. Specificity order: (you) > (reply).
  682. Important: The :not(#SP1) selectors and the !important are used for extra specificity.
  683. These are made extra specific so we can override ones from other userscripts.
  684. (because Lynx-- has an option to disable only this and also has the ability to customize the color)
  685. */
  686. /* Match your posts. This is easy. */
  687. body:not(#SP1#SP1) .yourPost {
  688. border-left: 3px dashed var(--markPostEdge_color1, ${color1}) !important;
  689. }
  690.  
  691. /*
  692. * Match replies:
  693. * This can be a simple .divMessage > .quoteLink
  694. * or it can be a .divMessage > details > .spoiler > s > u > .quoteLink (or something like that)
  695. */
  696. body:not(#SP1) .quotesYou {
  697. border-left: 2px solid var(--markPostEdge_color2, ${color2}) !important;
  698. }
  699. `);
  700. }
  701.  
  702. if (settings.markYouText) {
  703. const color1 = settings.markYouText_color1 || SETTINGS_DEFINITIONS.markYouText.color1Default;
  704. addMyStyle("lynx-mark-you-text", `
  705. .youName { color: var(--markYouText_color1, ${color1}); }
  706. .you { --link-color: var(--markYouText_color1, ${color1}); }
  707. `);
  708. }
  709.  
  710. if (settings.halfchanGreentexts) {
  711. addMyStyle("lynx-halfchanGreentexts",
  712. `.greenText {
  713. filter: brightness(110%);
  714. }
  715. `);
  716. }
  717. if (settings.fixPostConfirmStyling) {
  718. addMyStyle("lynx-fix-confirm-dialog",`
  719. /* ↓ The button disabled effect happens here. Now has a loading icon to let you know the post is going through! */
  720. input[value="Reload"]:disabled,
  721. input[type="submit"]:disabled,
  722. input[type="button"]:disabled,
  723. button:disabled {
  724. cursor: wait;
  725.  
  726. /* The animated loading icon that appears only for disabled buttons to give the illusion of loading. If your icon is .apng format, remove the .png extension from the hash. */
  727. background-image: url("/.media/2fa1b1615e7854cce8cfba5369afb8064b6b292a98ab9edf59bcb9893b7c3cb9");
  728.  
  729. /* Don't change this if you want to re-use the animated loading icon for your CSS theme. */
  730. padding-left: 20px;
  731.  
  732. /* Don't change this if you want to re-use the animated loading icon for your CSS theme. */
  733. background-size: 16px 16px;
  734.  
  735. /* Don't change this if you want to re-use the animated loading icon for your CSS theme. */
  736. background-repeat: no-repeat;
  737.  
  738. /* Don't change this if you want to re-use the animated loading icon for your CSS theme. */
  739. background-position: 2px center;
  740. }
  741. `);
  742. }
  743.  
  744. if (settings.showStubs === false) {
  745. addMyStyle("lynx-hide-stubs",`
  746. .postCell:has(> span.unhideButton.glowOnHover) {
  747. display: none;
  748. }
  749. `);
  750. }
  751.  
  752. if (settings.revealSpoilerText=="on") {
  753. addMyStyle("lynx-reveal-spoilertext1",`
  754. span.spoiler { color: white }
  755. `);
  756. } else if (settings.revealSpoilerText=="madoka") {
  757. addMyStyle("lynx-reveal-spoilertext2",`
  758. span.spoiler:not(:hover) {
  759. color: white;
  760. font-family: MadokaRunes !important;
  761. }
  762. `);
  763. }
  764.  
  765. } //End of runASAP()
  766.  
  767. //Everything in runAfterDom runs after document has loaded (like @run-at document-end)
  768. //Everything in runAfterDom runs after document has loaded (like @run-at document-end)
  769. //Everything in runAfterDom runs after document has loaded (like @run-at document-end)
  770. async function runAfterDom() {
  771. console.log("%cLynx Minus Minus Started with settings:", "color:rgb(0, 140, 255)", settings);
  772.  
  773. //Detect if the current page is a thread
  774. const url = window.location.href;
  775. const isThread = REGEX_THREAD.test(url);
  776.  
  777. //Keep these for now I guess.
  778. //Get the following window objects
  779. //unsafeWindow works on chrome and Tampermonkey FF, wrappedJSObject works on Firefox VM.
  780. //Chrome and firefox behavior is different here. In Chrome you can simply do 'api' to check if the variable is defined, in Firefox you need to do typeof api !== 'undefined'.
  781. //And a && will return the second element if the first is true.
  782.  
  783. // let windowAccessible = false;
  784. // const window_ref = (typeof unsafeWindow !== "undefined" && unsafeWindow) || (typeof window !== 'undefined' && window) || wrappedJSObject
  785. // const window_api = (typeof api !== 'undefined' && api) || window?.api || unsafeWindow?.api || wrappedJSObject?.api || undefined;
  786. // const window_posting = (typeof posting !== 'undefined' && posting) || window?.posting || unsafeWindow?.posting || wrappedJSObject?.posting || undefined;
  787. // const window_qr = (typeof qr !== 'undefined' && qr) || window?.qr || unsafeWindow?.qr || wrappedJSObject?.qr || undefined;
  788. // if (window_api && window_posting && window_qr) {
  789. // windowAccessible = true;
  790. // console.log(window_api)
  791. // } else {
  792. // //I think greasemonkey sandboxes the script. I use violentmonkey though
  793. // console.error("Lynx Minus Minus: This JS script is sandboxed and can't access page JS... (If you can read this, let me know what browser/extension does this. Or maybe the site just failed to load?)")
  794. // }
  795.  
  796. if (settings.preserveQuickReply === false) {
  797. const qrBody = document.getElementById("qrbody");
  798. if (qrBody) {
  799. qrBody.value = "";
  800. }
  801. }
  802.  
  803. function pageHotkeys(ev) {
  804. const key = ev.key.toLowerCase();
  805. //Ctrl+Q or Alt+R to open quick reply
  806. if ((ev.ctrlKey && key == "q") || (ev.altKey && key == "r")) {
  807. ev.preventDefault();
  808. //8chan's HTML will keep the text after a reload so attempt to clear it again
  809. const qrBody = document.getElementById("qrbody");
  810. if (settings.preserveQuickReply === false) {
  811. if (qrBody) {
  812. qrBody.value = "";
  813. }
  814. }
  815. const replyBtn = document.getElementById("replyButton");
  816. replyBtn?.click();
  817. qrBody?.focus();
  818. };
  819. //Alt+T to toggle thread watcher
  820. if (ev.altKey && (key == "t")) {
  821. ev.preventDefault();
  822. const watcherBtn = document.querySelector("body > nav a.watcherButton");
  823. if (watcherBtn) watcherBtn.click();
  824. }
  825. }
  826. document.addEventListener("keydown", pageHotkeys);
  827.  
  828. function createSettingsButton() {
  829. //Desktop
  830. document.querySelector("#navLinkSpan > .settingsButton").insertAdjacentHTML("afterend", `
  831. <span>/</span>
  832. <a id="navigation-lynxextended" class="coloredIcon lynxExtendedButton" title="LynxChan Extended-- Settings"></a>
  833. `);
  834. //Mobile
  835. document.querySelector("#sidebar-menu > ul > li > .settingsButton").parentElement.insertAdjacentHTML("afterend", `
  836. <li>
  837. <a id="navigation-lynxextended-mobile" class="coloredIcon lynxExtendedButton" title="LynxChan Extended-- Settings">Lynx Ex-- Settings</a>
  838. </li>
  839. `);
  840. document.querySelector("#navigation-lynxextended").addEventListener("click", openMenu);
  841. document.querySelector("#navigation-lynxextended-mobile").addEventListener("click", openMenu);
  842. }
  843.  
  844. //Register menu command for the settings button
  845. GM.registerMenuCommand("Show Options Menu", openMenu);
  846. try {
  847. createSettingsButton();
  848. } catch (error) {
  849. //Don't log errors on the disclaimer page (constantly seen by private browsing users)
  850. if (document.location.href.includes("disclaimer.html") === false) {
  851. console.log("Error while creating settings button:", error);
  852. }
  853. // return; //If the button creation fails, don't continue (not sure if this is needed)
  854. }
  855.  
  856. //Open the settings menu on the first run
  857. if (settings.firstRun) {
  858. settings.firstRun = false;
  859. await GM.setValue("firstRun", settings.firstRun);
  860. openMenu();
  861. }
  862.  
  863. //Show watched threads on page load
  864. if (settings.showWatcherOnLoad) {
  865. const watchedMenu = document.querySelector("body > #floatingMenusContainer > #watchedMenu");
  866. const watcherButton = document.querySelector("body > nav > #navLinkSpan > .watcherButton");
  867. if (watchedMenu && watcherButton) {
  868. if (watchedMenu?.style?.display === "none") {
  869. watcherButton.click();
  870. }
  871. }
  872. }
  873.  
  874. function replyKeyboardShortcuts(ev) {
  875. if (ev.ctrlKey) {
  876. let combinations = {
  877. "s":["[spoiler]","[/spoiler]"],
  878. "b":["'''","'''"],
  879. "u":["__","__"],
  880. "i":["''","''"],
  881. "d":["[doom]","[/doom]"],
  882. "m":["[moe]","[/moe]"]
  883. }
  884. for (var key in combinations) {
  885. //Accept regular key even if caps lock is enabled (prevent eating ctrl+shift+* combos for now?)
  886. if (ev.key == key || (ev.key.toLowerCase() == key && ev.shiftKey == false)) {
  887. ev.preventDefault();
  888. console.log("ctrl+"+key+" pressed in textbox")
  889. const textBox = ev.target;
  890. const tags = combinations[key];
  891. const selectionStart = textBox.selectionStart;
  892. const selectionEnd = textBox.selectionEnd;
  893.  
  894. if (selectionStart == selectionEnd) { //If there is nothing selected, make empty tags and center the cursor between it
  895. document.execCommand("insertText", false, tags[0] + tags[1]);
  896. //Center the cursor between tags
  897. textBox.selectionStart = textBox.selectionEnd = (textBox.selectionEnd - tags[1].length);
  898. } else {
  899. // Handle multiline text: wrap each line separately as the markers only work on single lines
  900. const selectedText = textBox.value.slice(selectionStart, selectionEnd);
  901. const lines = selectedText.split('\n');
  902. const wrappedLines = lines.map(line => tags[0] + line + tags[1]);
  903. const newText = wrappedLines.join('\n');
  904. document.execCommand("insertText", false, newText);
  905. }
  906. return;
  907. }
  908. }
  909. //Ctrl+Enter to send reply
  910. if (ev.key=="Enter") {
  911. document.getElementById("qrbutton")?.click()
  912. }
  913. }
  914. }
  915.  
  916.  
  917. function openMenu() {
  918. const oldMenu = document.getElementById("lynxExtendedMenu");
  919. if (oldMenu) {
  920. oldMenu.remove();
  921. return;
  922. }
  923. // Create options menu
  924. const menu = document.createElement("div");
  925. menu.id = "lynxExtendedMenu";
  926. menu.innerHTML = `
  927. <div class='settings-header'>
  928. <h3 style="text-align: center; color: var(--subject-color);">LynxChan Extended-- <a href="https://greasyfork.org/en/scripts/533169-lynxchan-extended-minus-minus/versions">Version ${GM.info.script.version}</a></h3>
  929. <!--<p style="text-align: center;"><a href="https://greasyfork.org/en/scripts/533169-lynxchan-extended-minus-minus/versions">Version ${GM.info.script.version}</a></p>-->
  930. <!-- <p style="text-align: center;">SCROLL DOWN to save your settings!</p><br> -->
  931. <a class='tab'>Main Settings</a> | <a class='tab'>Filtering</a> | <a class='tab'>Debug Options</a>
  932. </div>
  933. `;
  934.  
  935. const menuPages = document.createElement("div");
  936. menuPages.classList.add("settings-content");
  937.  
  938. //we use createElement() here instead of setting innerHTML so we can attach onclick to elements
  939. //...In the future, at least. There aren't any onclicks added yet.
  940. const settingsPage = document.createElement("div");
  941. settingsPage.classList.add("tab-page", 'page1');
  942. Object.keys(SETTINGS_DEFINITIONS).forEach((name) => {
  943. const setting = SETTINGS_DEFINITIONS[name];
  944. if (setting.hidden) {
  945. //pass
  946. } else if (setting.category && setting.category != "default") {
  947. //pass
  948. }
  949. else if (setting.type == "radio") {
  950. let html = `<span>${setting.desc}</span><br><form id="${name}" action='#'>`;
  951. for (const [value, description] of Object.entries(setting.options)) {
  952. html += `
  953. <label>
  954. <input name="${name}" type="radio" value="${value}" ${settings[name]==value ? "checked" : ""}>
  955. <span>${description}</span>
  956. </label><br>
  957. `;
  958. }
  959. html += `</form>${setting.nonewline ? '' : '<br>'}`;
  960. settingsPage.innerHTML += html;
  961. } else if (setting.type == "checkbox_multiple_dict") {
  962. const dict = settings[name]
  963. let html = `<span>${setting.desc}</span><br><form id="${name}" action='#'>`;
  964. for (const [key, description] of Object.entries(setting.options)) {
  965. html += `
  966. <label>
  967. <input name="${name}" type="checkbox" value="${key}" ${dict[key] ? "checked" : ""}>
  968. <span>${description}</span>
  969. </label><br>
  970. `;
  971. }
  972. html += `</form>${setting.nonewline ? '' : '<br>'}`;
  973. settingsPage.innerHTML += html;
  974. } else if (setting.type == "dropdown") {
  975. let html = `<label for="${name}">${setting.desc}:</label><select id="${name}">`
  976. Object.keys(setting['choices']).forEach(value => {
  977. html+=`<option value="${value}" ${settings[name]==value ? "selected" : ""}>${value}</option>`
  978. })
  979. html+=`</select><br>${setting.nonewline ? '' : '<br>'}`;
  980. settingsPage.innerHTML += html;
  981.  
  982. } else if (setting.type == "checkbox_with_colors") {
  983. let colorHtml = "";
  984. let colorFields = Object.keys(setting).filter(k => /^color\d+Default$/.test(k));
  985. colorFields.forEach((colorKey) => {
  986. const idx = colorKey.match(/^color(\d+)Default$/)[1];
  987. const colorValue = settings[`${name}_color${idx}`] || setting[`color${idx}Default`];
  988. const colorDesc = setting[`color${idx}Desc`] || "";
  989. colorHtml += `
  990. <label class="colorLabel">
  991. ${colorDesc}
  992. <input type="color" id="${name}_color${idx}" value="${colorValue}" ${settings[name] ? '' : 'disabled'}>
  993. </label>
  994. `;
  995. });
  996. settingsPage.innerHTML += `
  997. <label>
  998. <input type="checkbox" id="${name}" ${settings[name] ? "checked" : ""}>
  999. ${setting.desc}
  1000. </label>
  1001. ${colorHtml}
  1002. <br>${setting.nonewline ? '' : '<br>'}`;
  1003. } else if (setting.type == "textinput") {
  1004.  
  1005. let stringInputHtml = `<label>${setting.desc}</label>`
  1006. let inputFields = Object.keys(setting).filter(k => /^value\d+Default$/.test(k));
  1007. inputFields.forEach((inputKey) => {
  1008. const idx = inputKey.match(/^value(\d+)Default$/)[1];
  1009. const colorValue = settings[`${name}_value${idx}`] || setting[`value${idx}Default`];
  1010. const colorDesc = setting[`value${idx}Desc`] || "";
  1011. stringInputHtml += `
  1012. <label for="${name}_field${idx}">
  1013. ${colorDesc}
  1014. </label>
  1015. <input type="text" id="${name}_field${idx}" value="${colorValue}">
  1016. `;
  1017. });
  1018.  
  1019. settingsPage.innerHTML += stringInputHtml+`<br>`
  1020. } else {
  1021. settingsPage.innerHTML += `
  1022. <label>
  1023. <input type="checkbox" id="${name}" ${settings[name] ? "checked" : ""}>
  1024. ${setting.desc}
  1025. </label><br>${setting.nonewline ? '' : '<br>'}`;
  1026. }
  1027. })
  1028.  
  1029. const filteringPage = document.createElement("div");
  1030. filteringPage.classList.add("tab-page")
  1031. filteringPage.setAttribute("hidden","true") //Works same as css display:none
  1032. filteringPage.innerHTML = `
  1033. lorem ipsum girlsfrontline kuso (WIP WIP WIP)
  1034. <br>Syntax is identical to 4chanX. You can add image hashes using the hide button.
  1035. <br>The way this works is by using the image's path, since 8chan hashes the image based on content and stores it with a hashed filename.
  1036. <br>Can't remember what an image points to? You can do <span style="font-family:monospace">https://8chan.moe/.media/hash</span> (replacing 'hash' with the hash).
  1037. <br>
  1038. Filter by image hash
  1039. <textarea id='filterByImageHash' name="ImageHash" class="field" spellcheck="false" style="height:200px">${settings.filterByImageHash}</textarea>
  1040. Filter by image filename
  1041. <textarea id='filterByImageName' name="ImageName" class="field" spellcheck="false" style="height:200px">${settings.filterByImageName}</textarea>
  1042. `
  1043. //const imageFilteringTextarea = filteringPage.getElementsByTagName("textarea")[0];
  1044.  
  1045. const debuggingPage = document.createElement("div");
  1046. debuggingPage.classList.add("tab-page","page3");
  1047. debuggingPage.setAttribute("hidden","true");
  1048. debuggingPage.innerHTML = `
  1049. This is debug information
  1050. The below box is read only. You can't use it to import settings yet.
  1051. <textarea name="SettingsValues" disabled="" class='field' spellcheck='false' style="height:200px">${JSON.stringify(settings)}</textarea>
  1052. `
  1053.  
  1054. menuPages.appendChild(settingsPage);
  1055. menuPages.appendChild(filteringPage);
  1056. menuPages.appendChild(debuggingPage);
  1057. menu.appendChild(menuPages);
  1058. menu.innerHTML += `
  1059. <div class='settings-footer'>
  1060. <button id="saveSettings">Save</button>
  1061. <button id="closeMenu">Close</button>
  1062. <button id="resetSettings" style="float: right;">Reset</button>
  1063. </div>
  1064. `;
  1065.  
  1066. document.body.appendChild(menu);
  1067.  
  1068. //Init tabs
  1069. var tabs = menu.querySelectorAll(".tab");
  1070. var tabContainers = menu.querySelectorAll(".tab-page");
  1071.  
  1072. for (let i = 0; i < tabs.length; i++) {
  1073.  
  1074. // Copy i to idx using function closure. Without this i will get treated as a reference instead of a value
  1075. // (thanks JavaScript)
  1076. (function(idx){
  1077. tabs[idx].onclick = function() {
  1078. for (let j = 0; j < tabs.length; j++) {
  1079. //console.log("Selected button "+idx)
  1080. let btn = tabs[j];
  1081. let tabContent = tabContainers[j];
  1082.  
  1083. if (idx==j) {
  1084. btn.classList.add("active");
  1085. tabContent?.classList.add('active');
  1086. //Set HTML 'hidden' attribute that works like CSS display:none
  1087. tabContent?.removeAttribute("hidden");
  1088. }
  1089. else {
  1090. btn.classList.remove("active")
  1091. tabContent?.classList.remove("active");
  1092. tabContent?.setAttribute("hidden", "true");
  1093. }
  1094.  
  1095. btn.setAttribute("aria-selected", idx==j);
  1096. }
  1097. }
  1098. })(i);
  1099.  
  1100. if (i==0)
  1101. tabs[i].click();
  1102. }
  1103.  
  1104. // Save button functionality
  1105. document.getElementById("saveSettings").addEventListener("click", async () => {
  1106. Object.keys(SETTINGS_DEFINITIONS).forEach((name) => {
  1107. const setting = SETTINGS_DEFINITIONS[name];
  1108. if (!('hidden' in setting)) {
  1109. if (setting.type=="radio") {
  1110. settings[name] = menu.querySelector(`input[name="${name}"]:checked`).value;
  1111. } else if (setting.type == "checkbox_multiple_dict") {
  1112.  
  1113. const d = {}
  1114. menu.querySelectorAll(`input[name="${name}"]`).forEach(checkbox => {
  1115. d[checkbox.value] = checkbox.checked;
  1116. })
  1117. settings[name] = d;
  1118.  
  1119. } else if (setting.type=="dropdown" || setting.type=="textinput") {
  1120. settings[name] = document.getElementById(name).value;
  1121. } else if (setting.type=="checkbox_with_colors") {
  1122. settings[name] = document.getElementById(name).checked;
  1123. let colorFields = Object.keys(setting).filter(k => /^color\d+Default$/.test(k));
  1124. colorFields.forEach((colorKey) => {
  1125. const idx = colorKey.match(/^color(\d+)Default$/)[1];
  1126. const colorName = `${name}_color${idx}`;
  1127. const colorValue = document.getElementById(colorName).value;
  1128. settings[colorName] = colorValue;
  1129. // Set CSS variable on body (so it can be used without a refresh)
  1130. document.body.style.setProperty(`--${colorName}`, colorValue);
  1131. });
  1132. } else {
  1133. if (document.getElementById(name) != null)
  1134. settings[name] = document.getElementById(name).checked;
  1135. else
  1136. console.error("Failed to find an element named "+name+", so this setting cannot be saved");
  1137. }
  1138. }
  1139. })
  1140. console.log("Saving settings ",settings)
  1141. Promise.all(
  1142. Object.entries(settings).map(([key, value]) => GM.setValue(key, value))
  1143. ).then(_ => { //.then() executes a function when the promise is completed. In this case it's an anonymous arrow function
  1144. menu.remove();
  1145. alert("Settings saved!\nFor most settings you must refresh the page for the changes to take effect.\n\n(only color pickers don't need a refresh)");
  1146. });
  1147. });
  1148.  
  1149. // Reset button functionality
  1150. document.getElementById("resetSettings").addEventListener("click", async () => {
  1151. if (!confirm("Are you sure you want to reset all settings? This will delete all saved data.")) return;
  1152.  
  1153. const keys = await GM.listValues();
  1154. await Promise.all(keys.map(key => GM.deleteValue(key)));
  1155. alert("All settings have been reset.\nRefreshing automatically for the changes to take effect.");
  1156. menu.remove();
  1157. location.reload();
  1158. });
  1159.  
  1160. // Close button functionality
  1161. document.getElementById("closeMenu").addEventListener("click", () => {
  1162. menu.remove();
  1163. });
  1164.  
  1165. }
  1166.  
  1167. function createMarker(element, container, isReply) {
  1168. const pageHeight = document.body.scrollHeight;
  1169. const offsetTop = element.offsetTop;
  1170. const percent = offsetTop / pageHeight;
  1171. const top = (percent * 100).toFixed(2);
  1172.  
  1173. const marker = document.createElement("div");
  1174. marker.classList.add("marker");
  1175. if (isReply) {
  1176. marker.classList.add("alt");
  1177. }
  1178. marker.style.top = `${top}%`;
  1179. marker.dataset.postid = element.id;
  1180.  
  1181. marker.addEventListener("click", () => {
  1182. let elem = element?.previousElementSibling || element;
  1183. if (elem) elem.scrollIntoView({ behavior: "instant", block: "start" });
  1184. });
  1185.  
  1186. container.appendChild(marker);
  1187. }
  1188.  
  1189. function recreateScrollMarkers() {
  1190. let oldContainer = document.querySelector(".marker-container");
  1191. if (oldContainer) {
  1192. oldContainer.remove();
  1193. }
  1194. // Create marker container
  1195. const markerContainer = document.createElement("div");
  1196. markerContainer.classList.add("marker-container");
  1197. document.body.appendChild(markerContainer);
  1198.  
  1199. // Match and create markers for "my posts" (matches native)
  1200. document.querySelectorAll(".postCell:has(> .yourPost)")
  1201. .forEach((elem) => {
  1202. createMarker(elem, markerContainer, false);
  1203. });
  1204.  
  1205. // Match and create markers for "replies" (matches native)
  1206. document.querySelectorAll(".postCell:has(> .quotesYou)")
  1207. .forEach((elem) => {
  1208. createMarker(elem, markerContainer, true);
  1209. });
  1210. }
  1211.  
  1212. let postCount = 1;
  1213. const postIndexLookup = {};
  1214. function addPostCount(post, newpost = true) {
  1215. // const posts = Array.from(document.querySelectorAll(".innerOP, .divPosts > .postCell"));
  1216. if (post.querySelector(".postNum")) {
  1217. return;
  1218. }
  1219.  
  1220. const postInfoDiv = post.querySelector(".title");
  1221. const posterNameDiv = postInfoDiv.querySelector(".linkName");
  1222. const postNumber = postInfoDiv.querySelector(".linkQuote")?.textContent;
  1223. if (!postNumber) return;
  1224.  
  1225. let localCount = postCount;
  1226. if (newpost) {
  1227. postIndexLookup[postNumber] = localCount;
  1228. postCount++;
  1229. } else {
  1230. //Show cached post count for inlines & hovers
  1231. localCount = postIndexLookup[postNumber];
  1232. if (!localCount) return;
  1233. }
  1234.  
  1235. let newNode = document.createElement("span");
  1236. newNode.innerText = localCount;
  1237. newNode.className = "postNum index";
  1238. // if (localCount >= Infinity) { //knownBumpLimit
  1239. // newNode.style = "color: rgb(255, 4, 4); font-weight: bold;"
  1240. // }
  1241. postInfoDiv.insertBefore(newNode, posterNameDiv);
  1242. let foo = document.createTextNode("\u00A0"); // Non-breaking space
  1243. postInfoDiv.insertBefore(foo, posterNameDiv);
  1244. }
  1245.  
  1246.  
  1247. // 1. We can't access window.hiding because VM sandbox exposes before it gets attached(?)
  1248. // 2. We can't get a reference to an onclick handler
  1249. // 3. We can't replace with our own function because hiding.js keeps a list of hidden posts in memory
  1250. // So what do we do? We have to attach our own onclick and inject into the menu created by hiding.js at time of clicking!
  1251. const filteringHooks = function(post, newpost = false) {
  1252. const btn = post.querySelector(".hideButton")
  1253. const fileNames = Array.from(post.querySelectorAll(".originalNameLink[href]"));
  1254. if (fileNames.length == 0) //No attachments
  1255. return;
  1256. const positions = ['1st','2nd','3rd','4th','5th','6th','7th','8th','9th','10th']
  1257. //Part of the post but only on mobile
  1258. const mobileSelect = post.querySelector(".mobileSelect")
  1259.  
  1260. if (mobileSelect) {
  1261. //The way the select works on mobile is by putting the hide button on top of the dropdown
  1262. //So in order to add more options, we have to make our own and proxy the original!
  1263. let proxiedMobileSelect = mobileSelect.cloneNode(true)
  1264. mobileSelect.setAttribute("style","display:none");
  1265.  
  1266. for (let i = 0, nameElem; nameElem = fileNames[i]; i++) {
  1267. const pos = positions[i];
  1268. const imageFileName = nameElem.download;
  1269. let r = nameElem.href.split("/")
  1270. //The filename 8chan assigns on upload (contains file extension)
  1271. const imageUploadedFileName = r[r.length-1]
  1272. //The actual hash of the file
  1273. const imageHash = imageUploadedFileName.slice(0,-4)
  1274.  
  1275. proxiedMobileSelect.innerHTML += `<option data-hash="${imageHash}">Filter ${pos} Attachment Hash</option>`
  1276. if (imageUploadedFileName != imageFileName) { //If name == hash then this file has no filename
  1277. proxiedMobileSelect.innerHTML += `<option data-filename="${imageFileName}">Filter ${pos} Attachment Filename</option>`
  1278.  
  1279. }
  1280. }
  1281.  
  1282. proxiedMobileSelect.addEventListener("change", function(ev) {
  1283. let index = ev.target.selectedIndex;
  1284. //console.log(ev.target.selectedIndex)
  1285. if (index < mobileSelect.childElementCount) { //This option is in the original select so trigger original select
  1286. mobileSelect.selectedIndex = index;
  1287. mobileSelect.dispatchEvent(new Event("change"));
  1288. } else {
  1289. const o = ev.target.getElementsByTagName("option")[index];
  1290. let imageHash;
  1291. if (imageHash = o.getAttribute('data-hash')) {
  1292. settings.filterByImageHash += `\n/${imageHash}/`
  1293. GM.setValue('filterByImageHash',settings.filterByImageHash)
  1294. } else {
  1295. let imageFileName = o.getAttribute("data-filename")
  1296. settings.filterByImageName += `\n/${imageFileName}/`
  1297. GM.setValue('filterByImageName',settings.filterByImageName)
  1298. }
  1299. }
  1300. //console.log(mobileSelect.)
  1301. })
  1302.  
  1303. btn.appendChild(proxiedMobileSelect)
  1304. } else {
  1305. //The menu is destroyed when the user clicks out, so there is no need to worry about adding more than once.
  1306. btn.addEventListener("click", function(ev) {
  1307. const filteringMenu = post.querySelector(".floatingList.extraMenu");
  1308. if (filteringMenu == null) //Mobile device?
  1309. return;
  1310. //console.log(fileNames);
  1311.  
  1312. for (let i = 0, nameElem; nameElem = fileNames[i]; i++) {
  1313. const pos = positions[i];
  1314. const imageFileName = nameElem.download;
  1315. let r = nameElem.href.split("/")
  1316. //The filename 8chan assigns on upload (contains file extension)
  1317. const imageUploadedFileName = r[r.length-1]
  1318. //The actual hash of the file
  1319. const imageHash = imageUploadedFileName.slice(0,-4)
  1320.  
  1321.  
  1322. const newMenuOption = document.createElement("li");
  1323. newMenuOption.innerText = `Filter ${pos} Attachment Hash`
  1324. newMenuOption.setAttribute("title","Posts containing the hash of this attachment will automatically be hidden.")
  1325. filteringMenu.firstElementChild.insertAdjacentElement('beforeend',newMenuOption)
  1326. //For some bizarre reason, this works and closes the menu but the original onclick is adding the menu back again
  1327. newMenuOption.addEventListener("click", function(ev) {
  1328. settings.filterByImageHash += `\n/${imageHash}/`
  1329. GM.setValue('filterByImageHash',settings.filterByImageHash)
  1330. //The menu is already deleted by this point, so this does nothing
  1331. //post.querySelector(".floatingList.extraMenu")?.remove()
  1332. })
  1333. if (imageUploadedFileName != imageFileName) { //If name == hash then this file has no filename
  1334. let menuOption = document.createElement("li");
  1335. menuOption.innerText = `Filter ${pos} Attachment Filename`
  1336. menuOption.setAttribute("title","Posts with an attachment with a file name identical to this one will automatically be hidden.")
  1337. filteringMenu.firstElementChild.insertAdjacentElement("beforeend", menuOption);
  1338. menuOption.addEventListener("click", function(ev) {
  1339. settings.filterByImageName += `\n/${imageFileName}/`
  1340. GM.setValue('filterByImageName',settings.filterByImageName)
  1341. //post.querySelector(".floatingList.extraMenu")?.remove()
  1342. })
  1343.  
  1344. }
  1345.  
  1346.  
  1347. }
  1348. })
  1349. }
  1350.  
  1351.  
  1352. if (newpost) {
  1353. //Now check hashes...
  1354. if (post.querySelector(".unhideButton") != null) {
  1355. //This post is already hidden
  1356. return
  1357. }
  1358. //WELL WE CAN'T USE DETOURS... AND WE CAN'T GET A REFERENCE... SO I GUESS WE HAVE TO DO THIS THE HARD WAY
  1359. const hidePost = function(o) {
  1360. //console.log("Found match!! Hiding post "+post.id+", params are ",o)
  1361. let mobileSelect;
  1362. if (mobileSelect = post.querySelector(".mobileSelect")) {
  1363. if (o.poster === true)
  1364. mobileSelect.value = "Filter ID"
  1365. else
  1366. mobileSelect.value = "Hide"
  1367. mobileSelect.dispatchEvent(new Event("change"));
  1368. return true;
  1369.  
  1370. } else {
  1371. //Kill me
  1372. btn.click();
  1373. post.querySelector(".floatingList.extraMenu")?.setAttribute("style","display:none");
  1374.  
  1375. const filteringMenuItems = post.querySelector(".floatingList.extraMenu")?.querySelectorAll("li") || [];
  1376. for (let j = 0, htmlElement; htmlElement = filteringMenuItems[j]; j++) {
  1377. if (o.poster === true) {
  1378. if (htmlElement.innerText=="Filter ID") {
  1379. //console.log("Filter Post+ID because poster flag is true")
  1380. //htmlElement.click();
  1381. //Not sure what's going on here but I guess the event handler doesn't get attached fast enough?
  1382. setTimeout(() => htmlElement.click(), 1);
  1383. return true;
  1384. }
  1385. } else if (htmlElement.innerText=="Hide") {
  1386. //console.log("Click hide button!!")
  1387. setTimeout(() => htmlElement.click(), 1);
  1388. return true;
  1389. } else {
  1390. console.log(htmlElement.innerText)
  1391. }
  1392. }
  1393. }
  1394.  
  1395. return false;
  1396. }
  1397.  
  1398. for (let i = 0, nameElem; nameElem = fileNames[i]; i++) {
  1399. const imageFileName = nameElem.download;
  1400. let r = nameElem.href.split("/")
  1401. //The actual hash of the file
  1402. const imageHash = "/" + r[r.length-1].slice(0,-4) +"/"
  1403.  
  1404. //Hashes aren't regex so we only need to check if obj exists
  1405. let o = CustomFilters.ImageHash[imageHash];
  1406. if (o != null) {
  1407. hidePost(o);
  1408. return true;
  1409. }
  1410.  
  1411. const filterObjects = Object.values(CustomFilters.ImageName);
  1412. //console.log(filterObjects)
  1413. for (let j = 0; j < filterObjects.length; j++) {
  1414. if (filterObjects[j].regex.test(imageFileName)) {
  1415. hidePost(filterObjects[j]);
  1416. return true;
  1417. }
  1418. }
  1419. }
  1420. }
  1421. }
  1422.  
  1423. //Open source svg from iconify.design
  1424. const SVG_SEARCH = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" d="m20 20l-6-6"></path><path d="M15 9.5a5.5 5.5 0 1 1-11 0a5.5 5.5 0 0 1 11 0Z"></path></g></svg>`;
  1425. function filenameFeatures(post) {
  1426. const fileNames = Array.from(post.querySelectorAll(".originalNameLink[href]"));
  1427.  
  1428. //Feature: Distinguish videos, gifs and audio with an icon
  1429. //Theres also a mime type on .imgLink
  1430. if (settings.showVideoIcons) {
  1431. const videoExts = /\.(webm|mp4|mkv|mov|avi|flv|wmv|m4v|gif|apng|mp3|flac|opus|ogg|wav|aac|m4a|wma)$/i;
  1432. fileNames.forEach((nameElem) => {
  1433. if (videoExts.test(nameElem.download)) {
  1434. nameElem.classList.add("lynx-video");
  1435. }
  1436. });
  1437. }
  1438.  
  1439. //Last feature: Reverse image search buttons
  1440. //TODO MAYBE: Make this button open a small menu (like the post menu) instead of multiple buttons. AI can do it in 20 seconds.
  1441. // .some() will return true if any value in the dict is true
  1442. // So inverting this means no values are true, so return without doing anything
  1443. if (!Object.values(settings.reverseSearchOptions).some(Boolean)) {
  1444. return;
  1445. }
  1446.  
  1447. const regex_md5sum = /[0-9a-f]{32}/g;
  1448. const regex_pixiv = /(\d+)_p\d+/;
  1449. const alt_regex_pixiv = /illust\_(\d+)\_\d+\_\d+/; //Match pixiv images saved using the mobile app (ex. illust_123456_20250530_123456)
  1450.  
  1451. for (let i = fileNames.length-1; i>=0; i--)
  1452. {
  1453. const nameElem = fileNames[i];
  1454. const parent = nameElem.parentElement
  1455. if (parent.querySelector(".lynxReverseImageSearch")) {
  1456. return;
  1457. }
  1458. const attachmentFileName = nameElem.download;
  1459.  
  1460. const span = document.createElement("span");
  1461. span.classList.add("lynxReverseImageSearch");
  1462.  
  1463. let searchButtonsAdded = 0
  1464.  
  1465. let m;
  1466. if (
  1467. settings.reverseSearchOptions.pixiv && (
  1468. (m = regex_pixiv.exec(attachmentFileName)) !== null || (m = alt_regex_pixiv.exec(attachmentFileName)) !== null
  1469. )
  1470. ) {
  1471. span.innerHTML += `<a rel="noopener noreferrer" target="_blank" data-booruname="pixiv" href="https://pixiv.net/i/${m[1]}">${SVG_SEARCH}</a>`
  1472. searchButtonsAdded++;
  1473.  
  1474. }
  1475. //This is 'else if' because these options are mutually exclusive - a filename will never match pixiv AND an md5 hash
  1476. //Careful with this insane abuse of conditionals, the order of operations matters (&& is before = without parenthesis)
  1477. //And we don't want to match >1 because that could be an 8chan hash (There should only be 1 md5 hash in a file name anyways)
  1478. else if (settings.reverseSearchOptions.booru && (m = [...attachmentFileName.matchAll(regex_md5sum)]) && m?.length == 1) {
  1479. span.innerHTML += `<a rel="noopener noreferrer" target="_blank" data-booruname="${settings.reverseSearchBooruSite}" href="${SETTINGS_DEFINITIONS['reverseSearchBooruSite']['choices'][settings.reverseSearchBooruSite]}${m[0]}">${SVG_SEARCH}</a>`
  1480. searchButtonsAdded++;
  1481. }
  1482.  
  1483. if (settings.reverseSearchOptions.saucenao) {
  1484. //Logic: for everything thats not supported i.e. not an image use the thumbnail if its available
  1485. //Supported image extensions for Saucenao direct search
  1486. const imageExts = /\.(png|jpe?g|webp|bmp|gif)$/i;
  1487. let validImage = true;
  1488. let useThumbInstead = false;
  1489. let thumbUrl = null;
  1490. if (!imageExts.test(attachmentFileName)) {
  1491. // Not a supported image, try to find a thumbnail
  1492. const uploadCell = parent.closest('.uploadCell');
  1493. if (uploadCell) {
  1494. const thumbImg = uploadCell.querySelector('.imgLink > img');
  1495. const thumbSrc = thumbImg?.getAttribute("src");
  1496. if (thumbImg && thumbSrc?.startsWith('/.media/t_')) {
  1497. useThumbInstead = true;
  1498. thumbUrl = thumbSrc;
  1499. }
  1500. }
  1501. // If no valid thumbnail, don't add saucenao
  1502. if (!useThumbInstead) {
  1503. // Don't add saucenao button for this file
  1504. validImage = false;
  1505. }
  1506. }
  1507.  
  1508. const sauceNaoWrapper = function(ev) {
  1509. ev.preventDefault(); //Prevent <details> node from contracting
  1510. const a = ev.currentTarget;
  1511. if (a.classList?.contains("fetch-awaiting")) {
  1512. //Abort if theres an ongoing fetch to prevent accidental doubleclicks
  1513. return;
  1514. }
  1515.  
  1516. a.classList.add("fetch-awaiting");
  1517. const fetchUrl = useThumbInstead ? thumbUrl : nameElem.href;
  1518. const fetchName = useThumbInstead ? ("thumbnail_" + attachmentFileName) : attachmentFileName;
  1519. fetch(fetchUrl)
  1520. .then(resp => {
  1521. if (!resp.ok) throw new Error("Fetch failed");
  1522. return resp.blob();
  1523. })
  1524. .then(blob => {
  1525. let file = new File([blob], fetchName, {type: blob.type} );
  1526. let dataTransfer = new DataTransfer();
  1527. dataTransfer.items.add(file);
  1528. document.getElementById("saucenao_file_input").files = dataTransfer.files;
  1529. document.getElementById("saucenao_submit").click();
  1530. a.classList.remove("fetch-awaiting");
  1531. })
  1532. .catch(() => {
  1533. a.classList.remove("fetch-awaiting");
  1534. a.classList.add("fetch-failed");
  1535. });
  1536. }
  1537.  
  1538. if (validImage) {
  1539. const a = document.createElement("a");
  1540. a.setAttribute("data-booruname","saucenao")
  1541. // a.innerText = '🔍︎'
  1542. a.innerHTML = SVG_SEARCH;
  1543. a.addEventListener("click", sauceNaoWrapper)
  1544. span.appendChild(a);
  1545. //span.innerHTML += `<a data-booruname='saucenao' onclick=''>🔍︎</a>`
  1546. searchButtonsAdded++;
  1547. }
  1548. }
  1549.  
  1550. if (searchButtonsAdded > 1) {
  1551. span.classList.add("showSearchNames")
  1552. }
  1553.  
  1554. if (searchButtonsAdded > 0) {
  1555. parent.insertAdjacentElement("beforeend", span);
  1556. }
  1557. }
  1558. }
  1559.  
  1560. var idMap = {};
  1561. const glowpost = function(post, newpost = true) {
  1562. const list = post.querySelectorAll(".labelId");
  1563. const postNumber = post.querySelector(".linkQuote")?.textContent;
  1564. list.forEach((poster) => {
  1565. const bgColor = poster.style.backgroundColor;
  1566. if (newpost && idMap[bgColor] === undefined) {
  1567. idMap[bgColor] = postNumber;
  1568. poster.classList.add("glows");
  1569. poster.title = "This is the first post from this ID.";
  1570. } else if (!newpost && idMap[bgColor] == postNumber) {
  1571. poster.classList.add("glows");
  1572. poster.title = "This is the first post from this ID.";
  1573. }
  1574. });
  1575. }
  1576.  
  1577.  
  1578. const revealSpoilerImages = function(post) {
  1579. const spoilers = post.querySelectorAll(".imgLink > img:is([src='/spoiler.png'],[src*='/custom.spoiler'])");
  1580. // spoilers.forEach(spoiler => {
  1581. // spoiler.classList.add('spoiler-thumb');
  1582. // const parent = spoiler.parentElement;
  1583. // const hrefTokens = parent.href.split("/");
  1584. // const fileNameTokens = hrefTokens[4].split(".");
  1585.  
  1586. // const thumbUrl = `/.media/t_${fileNameTokens[0]}`;
  1587. // spoiler.src = thumbUrl;
  1588. // });
  1589. spoilers.forEach(image => {
  1590. image.classList.add('spoiler-thumb');
  1591.  
  1592. const uploadCell = image.closest('.uploadCell');
  1593. const parent = image.closest('a.imgLink');
  1594. const fileName = parent.href.split("/")[4];
  1595. const dimensionLabel = uploadCell.querySelector('.dimensionLabel');
  1596. const dataFilemime = parent.getAttribute('data-filemime');
  1597.  
  1598. // Set the full image as the thumbnail for images that are 220x220 pixels or smaller.
  1599. // This is a fix for small images because thumbnails are not generated for them.
  1600. // This crap does not apply to GIFs, GIFs always have generated thumbnails.
  1601. if (dimensionLabel && /^image\/.+$/.test(dataFilemime) && !/^image\/gif$/.test(dataFilemime) ) {
  1602. //Split at x or X, split only 2 times, then for each value in the split array convert to integer and assign to const 'dimensions'
  1603. const dimensions = dimensionLabel.textContent.trim().split(/x|×/, 2).map(v => parseInt(v));
  1604. if (dimensions.length === 2 && dimensions[0] <= 220 && dimensions[1] <= 220) {
  1605. image.src = `/.media/${fileName}?`;
  1606. } else {
  1607. image.src = `/.media/t_${fileName.split(".")[0]}`;
  1608. }
  1609. } else {
  1610. image.src = `/.media/t_${fileName.split(".")[0]}`;
  1611. }
  1612. })
  1613. }
  1614.  
  1615. if (settings.spoilerImageType.startsWith("reveal")) {
  1616. addMyStyle("lynx-reveal-spoilerimage",`
  1617. img.spoiler-thumb {
  1618. transition: 0.2s;
  1619. outline: 2px dotted #ff0000ee;
  1620. ${settings.spoilerImageType=="reveal_blur" ? "filter: blur(10px);" : ""}
  1621. }
  1622. img.spoiler-thumb:hover {
  1623. filter: blur(0);
  1624. }
  1625. `)
  1626. }
  1627.  
  1628. // Add functionality to apply the custom spoiler image CSS
  1629. let threadSpoilerFound = false;
  1630. let tsFallbackUsed = false;
  1631. function setThreadSpoiler(post) {
  1632. if (threadSpoilerFound) return;
  1633.  
  1634. let spoilerImageUrl = null;
  1635.  
  1636. //When the option is "threadAlt", fallback to "thread" if "threadAlt" doesn't exist yet.
  1637. if (settings.spoilerImageType == "threadAlt") {
  1638. const altSpoilerLink = Array.from(post.querySelectorAll('a.originalNameLink[download^="ThreadSpoilerAlt"]')).find(link => /\.(jpg|jpeg|png|webp)$/i.test(link.download));
  1639. spoilerImageUrl = altSpoilerLink ? altSpoilerLink.href : null;
  1640. tsFallbackUsed = false; //stop looking for threadAlt
  1641. }
  1642.  
  1643. if (settings.spoilerImageType == "thread" || (!spoilerImageUrl && !tsFallbackUsed && settings.spoilerImageType == "threadAlt")) {
  1644. const spoilerLink = Array.from(post.querySelectorAll('a.originalNameLink[download^="ThreadSpoiler"]')).find(link => /\.(jpg|jpeg|png|webp)$/i.test(link.download));
  1645. spoilerImageUrl = spoilerLink ? spoilerLink.href : null;
  1646. if (settings.spoilerImageType == "threadAlt") {
  1647. tsFallbackUsed = true; //Keep looking for threadAlt
  1648. }
  1649. } else if (settings.spoilerImageType == "test") {
  1650. const myArray = [
  1651. 'https://8chan.moe/.media/f76e9657d6b506115ccd0ade73d3d562777a441e4b6bb396610669396ff3032a.png',
  1652. 'https://8chan.moe/.media/1074fdb6eea4ba609910581e7824106736a1bcad446ace1ae0792b231b52cf9a.png',
  1653. 'https://8chan.moe/.media/c32b4de8490d7e77987f0e2a381d5935ffa6fec9b98c78ea7c05bd4381d6f64b.png',
  1654. 'https://8chan.moe/.media/bb225302110d52494ec2bea68693d566acee09767212ce4ee8c0d83d49cfa05b.png'
  1655. ];
  1656. spoilerImageUrl = myArray[Math.floor(Math.random() * myArray.length)];
  1657. addMyStyle("lynx-thread-spoiler-css1", `
  1658. body {
  1659. --spoiler-img: url("${spoilerImageUrl}")
  1660. }
  1661. .uploadCell:not(.expandedCell) a.imgLink:has(>img[src="/spoiler.png"]),
  1662. .uploadCell:not(.expandedCell) a.imgLink:has(>img[src*="/custom.spoiler"]) {
  1663. background-image: var(--spoiler-img);
  1664. background-size: cover;
  1665. background-position: center;
  1666. & > img {
  1667. opacity: 0;
  1668. }
  1669. }
  1670. `);
  1671. threadSpoilerFound = true;
  1672. return;
  1673. }
  1674.  
  1675. if (spoilerImageUrl) {
  1676. document.head?.querySelector("#lynx-thread-spoiler-css2")?.remove(); //Remove if the style already exists (from fallback)
  1677. addMyStyle("lynx-thread-spoiler-css2", `
  1678. ${settings.overrideBoardSpoilerImage ? '.uploadCell:not(.expandedCell) a.imgLink:has(>img[src*="/custom.spoiler"]),' : "" }
  1679. .uploadCell:not(.expandedCell) a.imgLink:has(>img[src="/spoiler.png"]) {
  1680. background-image: url("${spoilerImageUrl}");
  1681. background-size: cover;
  1682. background-position: center;
  1683. outline: dashed 2px #ff000090;
  1684. & > img {
  1685. opacity: 0;
  1686. }
  1687. }
  1688. `);
  1689. if (!tsFallbackUsed) {
  1690. threadSpoilerFound = true;
  1691. }
  1692. }
  1693. }
  1694.  
  1695. if (settings.spoilerImageType=="kachina") {
  1696. addMyStyle("lynx-kachinaSpoilers",`
  1697. ${settings.overrideBoardSpoilerImage ? '.uploadCell:not(.expandedCell) a.imgLink:has(>img[src*="/custom.spoiler"]),' : "" }
  1698. .uploadCell:not(.expandedCell) a.imgLink:has(>img[src="/spoiler.png"]) {
  1699. background-size: cover;
  1700. background-position: center;
  1701. margin-right:5px;
  1702. background-image: url("data:image/jpeg;base64,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");
  1703. & > img {
  1704. opacity: 0;
  1705. }
  1706. }
  1707. `)
  1708. }
  1709.  
  1710. function iterateAllPosts() {
  1711. //Get ALL posts (this does NOT include inlined posts and hovered posts)
  1712. const allPosts = document.querySelectorAll("#divThreads > .opCell > .innerOP, .divPosts > .postCell");
  1713. allPosts.forEach((post) => {
  1714. iterateSinglePost(post, true);
  1715. });
  1716. }
  1717.  
  1718. /**
  1719. * Processes a single post element.
  1720. *
  1721. * @param {HTMLElement} post - The post here can be an .innerPost or one of its containers
  1722. * @param {boolean} newpost - True if this is a new post in the thread (i.e. not a tooltip or inline)
  1723. */
  1724. function iterateSinglePost(post, newpost = false) {
  1725. // console.log("Lynx-- processing post", {post}, {newpost}, {batching});
  1726. filenameFeatures(post);
  1727. if (settings.glowFirstPostByID)
  1728. glowpost(post, newpost);
  1729. if (settings.spoilerImageType.startsWith("reveal"))
  1730. revealSpoilerImages(post);
  1731. if (settings.showPostIndex)
  1732. addPostCount(post, newpost);
  1733.  
  1734. //Run only if its a new post in the thread
  1735. if (newpost) {
  1736. if (settings.spoilerImageType=="thread" || settings.spoilerImageType=="threadAlt")
  1737. setThreadSpoiler(post);
  1738.  
  1739. //Below functions still have to iterate all posts, do these last and only when necessary.
  1740. //These are now manually ran outside this function for performance reasons.
  1741. // if (settings.showScrollbarMarkers)
  1742. // recreateScrollMarkers();
  1743. }
  1744. filteringHooks(post, newpost);
  1745. }
  1746.  
  1747. //ANYTHING BELOW ONLY RUNS ON THREAD PAGES (if (isThread))
  1748. //ANYTHING BELOW ONLY RUNS ON THREAD PAGES (if (isThread))
  1749. //99% of above are functions. They can be ignored.
  1750. if (isThread) {
  1751. if (settings.addKeyboardHandlers) {
  1752. document.getElementById("qrbody")?.addEventListener("keydown", replyKeyboardShortcuts);
  1753. document.getElementById("quick-reply")?.addEventListener('keydown',function(ev) {
  1754. if (ev.key == "Escape") {
  1755. document.getElementById("quick-reply")?.querySelector(".close-btn").click();
  1756. }
  1757. })
  1758. }
  1759. if (settings.reverseSearchOptions.saucenao) {
  1760. //have to shove this at the bottom of the document since the entire thread is inside a form div and I can't nest it
  1761. const formm =`
  1762. <form target="_blank" action="https://saucenao.com/search.php" method="POST" enctype="multipart/form-data" style="display:none">
  1763. <input type="file" name="file" size="50" id='saucenao_file_input'>
  1764. <input type="submit" accesskey="s" value="get sauce" id='saucenao_submit'>
  1765. </form>`
  1766. document.body.insertAdjacentHTML('beforeend', formm);
  1767. }
  1768. //Start running and observing
  1769. iterateAllPosts();
  1770. //Delay slow actions to let the page finish loading first.
  1771. if (settings.showScrollbarMarkers) {
  1772. setTimeout(() => recreateScrollMarkers(), 1);
  1773. }
  1774. //Observe posts and all their children
  1775. const observer = new MutationObserver((mt_callback) => {
  1776. let foundNewPost = false;
  1777. mt_callback.forEach(mut => {
  1778. if (mut.type == "childList" && mut.addedNodes?.length > 0) {
  1779. //console.log("MutationObserver!!!");
  1780. mut.addedNodes.forEach(node => {
  1781. //New posts, new inlined posts, new hovered posts all contain .innerPost and are always in a div container.
  1782. //New posts are div.postCell and new inlines are div.inlineQuote
  1783. if (node.tagName === "DIV") {
  1784. // console.log("lynx ~ observer:", {node}, {mut});
  1785. if (node.classList.contains("postCell")) {
  1786. foundNewPost = true;
  1787. iterateSinglePost(node, true);
  1788. } else if (node.classList.contains("inlineQuote")) {
  1789. iterateSinglePost(node, false);
  1790. }
  1791. }
  1792. });
  1793. }
  1794. });
  1795. //Manually run all remaining slow actions here
  1796. if (foundNewPost && settings.showScrollbarMarkers) {
  1797. recreateScrollMarkers();
  1798. }
  1799. });
  1800. observer.observe(document.querySelector(".divPosts"), {childList: true, subtree: true});
  1801.  
  1802. //Observe the hover tooltip (ignore everything else)
  1803. const toolObserver = new MutationObserver((mutationsList) => {
  1804. for (const mutation of mutationsList) {
  1805. if (mutation.type === 'childList') {
  1806. mutation.addedNodes.forEach(node => {
  1807. if (node.tagName === "DIV" && node.matches(".innerPost, .innerOP")) {
  1808. //New hover tooltip found
  1809. iterateSinglePost(node, false);
  1810. }
  1811. });
  1812. }
  1813. }
  1814. });
  1815. const quoteTooltip = document.body?.querySelector(":scope > div.quoteTooltip");
  1816. if (quoteTooltip) {
  1817. toolObserver.observe(quoteTooltip, {childList: true});
  1818. }
  1819. }
  1820. } //End of runAfterDom()
  1821.  
  1822. //Starting runAfterDom when the document is ready
  1823. waitForDom(runAfterDom);
  1824. })();