MeFi Navigator Redux

MetaFilter: navigate through users' comments, and highlight comments by OP and yourself

  1. // ==UserScript==
  2. // @name MeFi Navigator Redux
  3. // @namespace https://github.com/klipspringr/mefi-userscripts
  4. // @version 2025-05-05
  5. // @description MetaFilter: navigate through users' comments, and highlight comments by OP and yourself
  6. // @author Klipspringer
  7. // @supportURL https://github.com/klipspringr/mefi-userscripts
  8. // @license MIT
  9. // @match *://*.metafilter.com/*
  10. // @grant none
  11. // ==/UserScript==
  12.  
  13. const SVG_UP = `<svg xmlns="http://www.w3.org/2000/svg" hidden style="display:none"><path id="mfnr-up" fill="currentColor" d="M 0 93.339 L 50 6.661 L 100 93.339 L 50 64.399 L 0 93.339 Z" /></svg>`;
  14. const SVG_DOWN = `<svg xmlns="http://www.w3.org/2000/svg" hidden style="display:none"><path id="mfnr-down" fill="currentColor" d="M 100 6.69 L 50 93.31 L 0 6.69 L 50 35.607 L 100 6.69 Z" /></svg>`;
  15.  
  16. // CSS notes:
  17. // - mfnr-op needs to play nicely with .mod in threads where OP is a mod
  18. // - classic theme has different margins from modern, so we can't change margin-left without knowing what theme we're on
  19. // - relative positioning seems to work better
  20. const CLASSES = `<style>
  21. .mfnr-op {
  22. border-left: 5px solid #0004 !important;
  23. padding-left: 10px !important;
  24. position: relative !important;
  25. left: -15px !important;
  26. }
  27. @media (max-width: 550px) {
  28. .mfnr-op {
  29. left: -5px !important;
  30. }
  31. }
  32. .mfnr-self-badge {
  33. background-color: #C8E0A1;
  34. border-radius: 2px;
  35. color: #000;
  36. font-size: 0.8em;
  37. margin-left: 4px;
  38. padding: 0 4px;
  39. cursor: default;
  40. }
  41. .mfnr-nav {
  42. white-space: nowrap;
  43. }
  44. .mfnr-nav svg {
  45. vertical-align: middle;
  46. top: -1px;
  47. }
  48. </style>`;
  49.  
  50. const ATTR_BYLINE = "data-mfnr-byline";
  51.  
  52. const getCookie = (key) => {
  53. const s = RegExp(key + "=([^;]+)").exec(document.cookie);
  54. if (!s || !s[1]) return null;
  55. return decodeURIComponent(s[1]);
  56. };
  57.  
  58. const markSelf = (targetNode) => {
  59. const span = document.createElement("span");
  60. span.classList.add("mfnr-self-badge");
  61. span.textContent = "me";
  62. targetNode.after(span);
  63. };
  64.  
  65. const markOP = (targetNode) =>
  66. targetNode.parentElement.parentElement.classList.add("mfnr-op");
  67.  
  68. const createLink = (href, svgHref) => {
  69. const a = document.createElement("a");
  70. a.setAttribute("href", "#" + href);
  71. const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
  72. svg.setAttribute("width", "1em");
  73. svg.setAttribute("viewBox", "0 0 100 100");
  74. svg.setAttribute("class", "mfnr-nav");
  75. const use = document.createElementNS("http://www.w3.org/2000/svg", "use");
  76. use.setAttribute("href", "#" + svgHref);
  77. svg.appendChild(use);
  78. a.appendChild(svg);
  79. return a;
  80. };
  81.  
  82. const processByline = (
  83. bylineNode,
  84. user,
  85. anchor,
  86. anchors,
  87. firstRun,
  88. self = null,
  89. op = null
  90. ) => {
  91. // don't mark self or OP more than once
  92. if (firstRun || !bylineNode.hasAttribute(ATTR_BYLINE)) {
  93. if (self !== null && user === self) markSelf(bylineNode);
  94. if (op !== null && user === op) markOP(bylineNode);
  95. bylineNode.setAttribute(ATTR_BYLINE, "");
  96. }
  97.  
  98. if (anchors.length <= 1) return;
  99.  
  100. const i = anchors.indexOf(anchor);
  101. const previous = anchors[i - 1];
  102. const next = anchors[i + 1];
  103.  
  104. const navigator = document.createElement("span");
  105. navigator.setAttribute("class", "mfnr-nav");
  106.  
  107. const nodes = ["["];
  108. if (previous) nodes.push(createLink(previous, "mfnr-up"));
  109. nodes.push(anchors.length);
  110. if (next) nodes.push(createLink(next, "mfnr-down"));
  111. nodes.push("]");
  112.  
  113. navigator.append(...nodes);
  114. bylineNode.parentElement.appendChild(navigator);
  115. };
  116.  
  117. const run = (subsite, self, firstRun) => {
  118. const start = performance.now();
  119.  
  120. const opHighlight = subsite !== "ask" && subsite !== "projects"; // don't highlight OP on subsites with this built in
  121.  
  122. // if not first run, remove any existing navigators (from both post and comments)
  123. if (!firstRun)
  124. document.querySelectorAll("span.mfnr-nav").forEach((n) => n.remove());
  125.  
  126. // post node
  127. // tested on all subsites, modern and classic, 2025-04-10
  128. const postNode = document.querySelector(
  129. "div.copy > span.smallcopy > a:first-child"
  130. );
  131. const op = postNode.textContent.trim();
  132.  
  133. // initialise with post
  134. const bylines = [[op, "top"]];
  135. const mapUsersAnchors = new Map([[op, ["top"]]]);
  136.  
  137. // comment nodes, excluding live preview
  138. // tested on all subsites, modern and classic, 2025-04-10
  139. const commentNodes = document.querySelectorAll(
  140. "div.comments:not(#commentform *) > span.smallcopy > a:first-child"
  141. );
  142.  
  143. for (const node of commentNodes) {
  144. const user = node.textContent.trim();
  145.  
  146. const anchorElement =
  147. node.parentElement.parentElement.previousElementSibling;
  148. const anchor = anchorElement.getAttribute("name");
  149.  
  150. bylines.push([user, anchor]);
  151.  
  152. const anchors = mapUsersAnchors.get(user) ?? [];
  153. mapUsersAnchors.set(user, anchors.concat(anchor));
  154. }
  155.  
  156. for (const [i, bylineNode] of [postNode, ...commentNodes].entries())
  157. processByline(
  158. bylineNode,
  159. bylines[i][0],
  160. bylines[i][1],
  161. mapUsersAnchors.get(bylines[i][0]),
  162. firstRun,
  163. self,
  164. opHighlight && i > 0 ? op : null
  165. );
  166.  
  167. console.log(
  168. "mefi-navigator-redux",
  169. firstRun ? "first-run" : "new-comments",
  170. 1 + commentNodes.length,
  171. Math.round(performance.now() - start) + "ms"
  172. );
  173. };
  174.  
  175. (() => {
  176. if (
  177. !/^\/(\d|comments\.mefi)/.test(window.location.pathname) ||
  178. /rss$/.test(window.location.pathname)
  179. )
  180. return;
  181.  
  182. document.body.insertAdjacentHTML("beforeend", SVG_UP + SVG_DOWN);
  183. document.body.insertAdjacentHTML("beforeend", CLASSES);
  184.  
  185. const subsite = window.location.hostname.split(".", 1)[0];
  186. const self = getCookie("USER_NAME");
  187.  
  188. const newCommentsElement = document.getElementById("newcomments");
  189. if (newCommentsElement) {
  190. const observer = new MutationObserver(() => run(subsite, self, false));
  191. observer.observe(newCommentsElement, { childList: true });
  192. }
  193.  
  194. run(subsite, self, true);
  195. })();