[Lemmy] Sort Posts, Comments, Communities & Search

Sort Lemmy posts, comments, communities & search. Reload the webpage after changing the sort type in menu to take effect. To make this script runnable, the CSP for the website must be disabled/modified/removed using an addon.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         [Lemmy] Sort Posts, Comments, Communities & Search
// @include      /^https:\/\/lemmy(?:[^.]+)?\./
// @include      /^https:\/\/feddit\./
// @match        https://aussie.zone/*
// @match        https://beehaw.org/*
// @match        https://discuss.tchncs.de/*
// @match        https://hexbear.net/*
// @match        https://infosec.pub/*
// @match        https://jlai.lu/*
// @match        https://midwest.social/*
// @match        https://programming.dev/*
// @match        https://reddthat.com/*
// @match        https://sh.itjust.works/*
// @match        https://slrpnk.net/*
// @match        https://sopuli.xyz/*
// @noframes
// @run-at       document-start
// @inject-into  page
// @grant        GM_deleteValue
// @grant        GM_getValues
// @grant        GM_registerMenuCommand
// @grant        GM_setValue
// @grant        GM_unregisterMenuCommand
// @grant        unsafeWindow
// @namespace    Violentmonkey Scripts
// @author       SedapnyaTidur
// @version      1.0.6
// @license      MIT
// @revision     12/23/2025, 1:56:21 AM
// @description  Sort Lemmy posts, comments, communities & search. Reload the webpage after changing the sort type in menu to take effect. To make this script runnable, the CSP for the website must be disabled/modified/removed using an addon.
// ==/UserScript==

(function() {
  'use strict';

  const posts = {
    Hot: 'Hot',
    Active: 'Active',
    Scaled: 'Scaled',
    Controversial: 'Controversial',
    New: 'New',
    Old: 'Old',
    MostComments: 'Most Comments',
    NewComments: 'New Comments',
    TopHour: 'Top Hour',
    TopSixHour: 'Top 6 Hours',
    TopTwelveHour: 'Top 12 Hours',
    TopDay: 'Top Day',
    TopWeek: 'Top Week',
    TopMonth: 'Top Month',
    TopThreeMonths: 'Top 3 Months',
    TopSixMonths: 'Top 6 Months',
    TopNineMonths: 'Top 9 Months',
    TopYear: 'Top Year',
    TopAll: 'Top All Time'
  };
  const comments = ['Hot', 'Top', 'Controversial', 'New', 'Old'];

  // Based on the keys of "posts" above and not the values for posts, communities & search.
  const defaults = {
    posts: 'Active',
    comments: 'Hot',
    communities: 'TopMonth',
    search: 'TopAll'
  };

  //https://lemmy.ml/?dataType=Post&listingType=Subscribed&sort=New
  //https://lemmy.world/comment/2421890?sort=New

  const window = unsafeWindow;
  let { postsSortBy, commentsSortBy, communitiesSortBy, searchSortBy, reload, hideSettings } = GM_getValues({
    postsSortBy: defaults.posts,
    commentsSortBy: defaults.comments,
    communitiesSortBy: defaults.communities,
    searchSortBy: defaults.search,
    reload: false,
    hideSettings: true
  });

  const configs = [{
    path: /^\/(?:$|c\/)/,
    defaultSort: defaults.posts,
    sort: postsSortBy
  }, {
    path: /^\/(?:comment|post)\//,
    defaultSort: defaults.comments,
    sort: commentsSortBy
  }, {
    path: /^\/communities/,
    defaultSort: defaults.communities,
    sort: communitiesSortBy
  }, {
    path: /^\/search/,
    defaultSort: defaults.search,
    sort: searchSortBy
  }];

  if (reload) GM_deleteValue('reload');

  const redirect = function() {
    const location = window.location;
    // For URL like this "https://lemmy.ca/search?" remove the "?" at the end and window.location.search is empty.
    const href = location.href.replace(/([^=])\?+$/, '$1');
    const path = location.pathname;
    const search = location.search;

    // May redirect to a different URL for the first visit.
    for (const config of configs) {
      if (config.path.test(path)) {
        if (!search) { //window.location.search is empty.
          if (config.sort !== config.defaultSort) {
            window.stop();
            location.replace(href + `?sort=${config.sort}`);
            return true;
          }
        } else if (/[?&]sort=[^&]+/.test(search)) {
          if (!reload && !search.includes(`sort=${config.sort}`)) {
            window.stop();
            location.replace(href.replace(/(.)sort=[^&]+/, `$1sort=${config.sort}`));
            return true;
          }
        } else {
          window.stop();
          location.replace(href + `&sort=${config.sort}`);
          return true;
        }
      }
    }
    return false;
  };

  if (redirect()) return;
  window.addEventListener('beforeunload', () => GM_setValue('reload', true), false);

  let attrObserver, currURL = window.location.href, first = true;
  let searchInterval, searchTimeout, scrollInterval = 0, scrollTimeout = 0, startInterval, startTimeout;
  const maxScrollHistory = 20, scrolls = [];
  let popstate = false, scrollIndex = 0, scrollAmountForPost = 0;
  const scrollTargets = [{
    path: /^\/$/,
    query: ':scope > div#root > div > main > div > div > div > div > div.main-content-wrapper > div > :is(div[class*="post-listings"],ul[class*="comments"]) > :is(div[class*="post-listing"],li[class*="comment"]) > :first-child'
  }, {
    path: /^\/c\//,
    query: ':scope > div#root > div > main > div > div > div > div > div.post-listings:has(> div.post-listing > :first-child, > div:not([class]))'
  }, {
    path: /^\/post\//,
    query: ':scope > div#root > div > main > div > div > div > div > div:not([class])'
  }, {
    path: /^\/communities/,
    query: ':scope > div#root > div > main > div > div > div > div.table-responsive > table > :first-child'
  }, {
    path: /^\/search/,
    query: ':scope > div#root > div > main > div > div > h3'
  }, {
    path: /^\/modlog/,
    query: ':scope > div#root > div > main > div > div > div.table-responsive > table > :first-child'
  }, {
    path: /^\/instances/,
    query: ':scope > div#root > div > main > div > div > div > div > div > div > div.active > div.table-responsive > table > :first-child'
  }];

  const saveScroll = function(href, scrollY) {
    if (scrollIndex === (maxScrollHistory << 1)) scrollIndex = 0;
    scrolls[scrollIndex++] = href;
    scrolls[scrollIndex++] = scrollY;
  };

  // Cycle through the array reversely.
  const getScroll = function(scrollY = 0) {
    for (let i = Math.max(0, scrollIndex - 4), n = 0; n < scrolls.length; i = (scrolls.length + (i - 2)) % scrolls.length, n += 2) {
      if (scrolls[i] === window.location.href) return scrolls[i + 1];
    } return scrollY;
  };

  // "inject-into page" is a must for this to work.
  const pushState = window.History.prototype.pushState;
  window.History.prototype.pushState = function(state, unused, url) {
    saveScroll(window.location.href, window.scrollY);
    popstate = false;

    const location = new URL(arguments[2], window.location.href);
    const href = location.href;
    const path = location.pathname;
    const search = location.search;

    for (const config of configs) {
      if (config.path.test(path)) {
        if (!search) { // Consume if window.location.search is empty.
          if (config.sort !== config.defaultSort) {
            arguments[2] = href + `?sort=${config.sort}`;
          }
        } else if (!/[?&]sort=[^&]+/.test(search)) { // So that users can change to different sort types.
          arguments[2] = href + `&sort=${config.sort}`;
        }
        // Avoid duplicate URLs in history when clicking the top-left icon/label.
        if(arguments[2] === window.location.href && window.location.pathname === '/') return window.history.go(0);
        break;
      }
    }
    return pushState.apply(this, arguments);
  };

  // There is no back/forward button listeners, so this is the best we could do.
  // onpopstate() is null when the document is not ready, that is why we use addEventListener().
  window.addEventListener('popstate', function(event) {
    saveScroll(currURL, window.scrollY); // Return from/to.
    popstate = true;
    window.clearInterval(scrollInterval);
    window.clearTimeout(scrollTimeout);
    scrollInterval = scrollTimeout = 0;
  }, true);

  // So many trash make the device runs like a snail.
  const setItem = window.Object.getPrototypeOf(window.sessionStorage).setItem;
  window.Object.getPrototypeOf(window.sessionStorage).setItem = function(keyName, keyValue) {
    if(arguments[0].startsWith('scrollPosition')) return;
    return setItem.apply(this, arguments);
  };

  // Took me like forever to fix this. Imagine from v1.0.1 to v1.0.6. Not perfect but good enough.
  const scrollTo = window.scrollTo;
  window.scrollTo = function(options) {
    window.clearInterval(scrollInterval); // These two can't be called in reset() because the MutationObserver triggered a bit late.
    window.clearTimeout(scrollTimeout);

    if (!/^\/(?:$|c\/|post\/|communities|search|modlog|instances)/.test(window.location.pathname)) return;
    if (!popstate && window.location.pathname.startsWith('/post/')) return;

    let query;
    for (const object of scrollTargets) {
      if (object.path.test(window.location.pathname)) {
        query = object.query;
        break;
      }
    }
    if (!query) return;
    scrollTimeout = window.setTimeout(() => {
      window.clearInterval(scrollInterval);
    }, 30000);
    scrollInterval = window.setInterval((query, args) => {
      if (!document.body.querySelector(query)) return; // Wait until the page is completely ready/loaded.
      window.clearInterval(scrollInterval);
      window.clearTimeout(scrollTimeout);
      scrollInterval = scrollTimeout = 0;
      args[0].top = getScroll(args[0].top);
      scrollTo.apply(this, args);
    }, 500, query, arguments);
  };

  // Dirty trick to make the visited posts highlighted via a style a:visited.
  // I have this filter in my uBlock Origin:
  // *##:root a:visited:style(color: rgb(218,112,214) !important; font-weight: 900 !important;)
  const changeLinks = function() {
    searchTimeout = window.setTimeout(() => {
      window.clearInterval(searchInterval);
    }, 30000);

    searchInterval = window.setInterval(() => {
      const targets = document.body.querySelectorAll('a[href^="/post/"]');
      if (targets.length < 6) return;
      window.clearInterval(searchInterval);
      window.clearTimeout(searchTimeout);
      searchInterval = searchTimeout = 0;

      for (const anchor of targets) {
        const href = anchor.href; // Complete URL.
        const search = href.replace(/^[^?]+/, '');
        if (!search) {
          if (commentsSortBy !== defaults.comments) {
            anchor.href = href + `?sort=${commentsSortBy}`;
          }
        } else if (!/[?&]sort=[^&]+/.test(search)) {
          anchor.href = href + `&sort=${commentsSortBy}`;
        }
        if (first) { // May get overriden by Lemmy.
          first = false;
          attrObserver = new MutationObserver(changeLinks);
          attrObserver.observe(anchor, { attributes: true });
        }
      }
    }, 500);
  };

  const reset = function() {
    window.clearInterval(searchInterval);
    window.clearTimeout(searchTimeout);
    if (attrObserver) {
      attrObserver.disconnect();
      attrObserver = undefined;
    }
    searchInterval = searchTimeout = 0;
    first = true;
  };

  const start = function() {
    startTimeout = window.setTimeout(() => {
      window.clearInterval(startInterval);
    }, 10000);

    startInterval = window.setInterval(() => {
      if (!document.head) return;
      window.clearInterval(startInterval);
      window.clearTimeout(startTimeout);
      new MutationObserver(mutations => {
        for (const mutation of mutations) {
          for (const node of mutation.addedNodes) {
            if (node instanceof HTMLLinkElement && node.rel === 'canonical' && window.location.href !== currURL) {
              currURL = window.location.href;
              reset();
              if (/^\/(?:$|c\/|search)/.test(window.location.pathname)) changeLinks();
              return;
            }
          }
        }
      }).observe(document.head, { childList: true });
    }, 500);
  };

  start();
  // Change visited links for the first time or reload.
  if (/^\/(?:$|c\/|search)/.test(window.location.pathname)) changeLinks();

  const next = function(array, startIndex, sortBy) {
    for (let i = startIndex; i < array.length; ++i) {
      if (array[i] === sortBy) {
        if (i === array.length - 1) return array[startIndex];
        return array[i + 1];
      }
    }
  };

  const menu = [{
    title: 'Posts: 《{}》',
    options: { id: '0', autoClose: false, title: 'Click to change the posts sort type.' },
    init: function() {
      this.title_ = this.title.replace('{}', posts[postsSortBy]);
      if (!hideSettings) this.id = GM_registerMenuCommand(this.title_, this.click, this.options);
      return this;
    },
    click: function(event) {
      postsSortBy = next(Object.keys(posts), 0, postsSortBy);
      GM_setValue('postsSortBy', postsSortBy);
      menu[0].title_ = menu[0].title.replace('{}', posts[postsSortBy]);
      menu.forEach(object => GM_unregisterMenuCommand(object.id));
      for (let i = 0; i < menu.length; ++i) {
        menu[i].id = GM_registerMenuCommand(menu[i].title_, menu[i].click, menu[i].options);
      }
    },
  }.init(), {
    title: 'Comments: 《{}》',
    options: { id: '1', autoClose: false, title: 'Click to change the comments sort type.' },
    init: function() {
      this.title_ = this.title.replace('{}', commentsSortBy);
      if (!hideSettings) this.id = GM_registerMenuCommand(this.title_, this.click, this.options);
      return this;
    },
    click: function(event) {
      commentsSortBy = next(comments, 0, commentsSortBy);
      GM_setValue('commentsSortBy', commentsSortBy);
      menu[1].title_ = menu[1].title.replace('{}', commentsSortBy);
      menu.forEach(object => GM_unregisterMenuCommand(object.id));
      for (let i = 0; i < menu.length; ++i) {
        menu[i].id = GM_registerMenuCommand(menu[i].title_, menu[i].click, menu[i].options);
      }
    },
  }.init(), {
    title: 'Communities: 《{}》',
    options: { id: '2', autoClose: false, title: 'Click to change the communities sort type.' },
    init: function() {
      this.title_ = this.title.replace('{}', posts[communitiesSortBy]);
      if (!hideSettings) this.id = GM_registerMenuCommand(this.title_, this.click, this.options);
      return this;
    },
    click: function(event) {
      communitiesSortBy = next(Object.keys(posts), 0, communitiesSortBy);
      GM_setValue('communitiesSortBy', communitiesSortBy);
      menu[2].title_ = menu[2].title.replace('{}', posts[communitiesSortBy]);
      menu.forEach(object => GM_unregisterMenuCommand(object.id));
      for (let i = 0; i < menu.length; ++i) {
        menu[i].id = GM_registerMenuCommand(menu[i].title_, menu[i].click, menu[i].options);
      }
    },
  }.init(), {
    title: 'Search: 《{}》',
    options: { id: '3', autoClose: false, title: 'Click to change the search sort type.' },
    init: function() {
      this.title_ = this.title.replace('{}', posts[searchSortBy]);
      if (!hideSettings) this.id = GM_registerMenuCommand(this.title_, this.click, this.options);
      return this;
    },
    click: function(event) {
      searchSortBy = next(Object.keys(posts), 3, searchSortBy);
      GM_setValue('searchSortBy', searchSortBy);
      menu[3].title_ = menu[3].title.replace('{}', posts[searchSortBy]);
      menu.forEach(object => GM_unregisterMenuCommand(object.id));
      for (let i = 0; i < menu.length; ++i) {
        menu[i].id = GM_registerMenuCommand(menu[i].title_, menu[i].click, menu[i].options);
      }
    },
  }.init(), {
    title: '{} Settings',
    choices: [ 'Show', 'Hide' ],
    options: { id: '4', autoClose: false, title: 'Click to show or hide settings.' },
    init: function() {
      this.title_ = this.title.replace('{}', this.choices[Number(!hideSettings)]);
      this.id = GM_registerMenuCommand(this.title_, this.click, this.options);
      return this;
    },
    click: function(event) {
      hideSettings = !hideSettings;
      GM_setValue('hideSettings', hideSettings);
      menu[4].title_ = menu[4].title.replace('{}', menu[4].choices[Number(!hideSettings)]);
      menu.forEach(object => GM_unregisterMenuCommand(object.id));
      for (let i = (hideSettings) ? menu.length - 1 : 0; i < menu.length; ++i) {
        menu[i].id = GM_registerMenuCommand(menu[i].title_, menu[i].click, menu[i].options);
      }
    },
  }.init()];
})();