[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.

// ==UserScript==
// @name         [Lemmy] Sort Posts, Comments, Communities & Search
// @match        https://aussie.zone/*
// @match        https://beehaw.org/*
// @match        https://discuss.tchncs.de/*
// @match        https://feddit.nl/*
// @match        https://feddit.org/*
// @match        https://feddit.uk/*
// @match        https://hexbear.net/*
// @match        https://infosec.pub/*
// @match        https://jlai.lu/*
// @match        https://lemmy.blahaj.zone/*
// @match        https://lemmy.ca/*
// @match        https://lemmy.dbzer0.com/*
// @match        https://lemmy.ml/*
// @match        https://lemmy.today/*
// @match        https://lemmy.world/*
// @match        https://lemmy.zip/*
// @match        https://lemmybefree.net/*
// @match        https://lemmygrad.ml/*
// @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        unsafeWindow
// @namespace    Violentmonkey Scripts
// @author       SedapnyaTidur
// @version      1.0.0
// @license      MIT
// @revision     8/18/2025, 11:39:49 PM
// @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'
  };

  let { postsSortBy, commentsSortBy, communitiesSortBy, searchSortBy, reload } = GM_getValues({ postsSortBy: defaults.posts, commentsSortBy: defaults.comments, communitiesSortBy: defaults.communities, searchSortBy: defaults.search, reload: false });
  const window = unsafeWindow;
  let attrObserver, currURL = window.location.href, first = true;
  let searchInterval, searchTimeout, startInterval, startTimeout;

  const configs = [{
    path: /^(?:\/$|\/c\/)/,
    defaultSort: defaults.posts,
    sort: postsSortBy
  }, {
    path: /^\/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 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;
        }
      } else if (/[?&]sort=[^&]+/.test(search)) {
        if (!reload && !search.includes(`sort=${config.sort}`)) {
          window.stop();
          location.replace(href.replace(/(.)sort=[^&]+/, `$1sort=${config.sort}`));
          return;
        }
      } else {
        window.stop();
        location.replace(href + `&sort=${config.sort}`);
        return;
      }
    }
  }

  // "inject-into page" is a must for this to work.
  const pushState = window.History.prototype.pushState;
  window.History.prototype.pushState = function() {
    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);
  };

  // Dirty trick to make the visited posts highlighted via a style a:visited.
  const changeLinks = function() {
    searchTimeout = setTimeout(() => {
      clearInterval(searchInterval);
    }, 5000);

    searchInterval = setInterval(() => {
      const targets = document.body.querySelectorAll('a[href^="/post/"]');
      if (targets.length < 6) return;
      clearInterval(searchInterval);
      clearTimeout(searchTimeout);
      searchInterval = 0;
      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() {
    clearInterval(searchInterval);
    clearTimeout(searchTimeout);
    if (attrObserver) {
      attrObserver.disconnect();
      attrObserver = undefined;
    }
    searchInterval = 0;
    searchTimeout = 0;
    first = true;
  };

  const start = function() {
    startTimeout = setTimeout(() => {
      clearInterval(startInterval);
    }, 5000);

    startInterval = setInterval(() => {
      if (!document.body) return;
      clearInterval(startInterval);
      clearTimeout(startTimeout);
      new MutationObserver(() => {
        if (window.location.href === currURL) return;
        currURL = window.location.href;
        reset();
        if (/^(?:\/$|\/c\/|\/search)/.test(window.location.pathname)) {
          changeLinks();
          return;
        }
      }).observe(document.body, { childList: true, subtree: true });
    }, 500);
  };

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

  start();

  window.addEventListener('beforeunload', () => {
    GM_setValue('reload', true);
  }, false);

  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 clickPosts = function() {
    postsSortBy = next(Object.keys(posts), 0, postsSortBy);
    GM_setValue('postsSortBy', postsSortBy);
    GM_registerMenuCommand(`Posts: 《${posts[postsSortBy]}》`, clickPosts, { id: '0', autoClose: false, title: 'Click to change the posts sort type.' });
  };
  const clickComments = function() {
    commentsSortBy = next(comments, 0, commentsSortBy);
    GM_setValue('commentsSortBy', commentsSortBy);
    GM_registerMenuCommand(`Comments: 《${commentsSortBy}》`, clickComments, { id: '1', autoClose: false, title: 'Click to change the comments sort type.' });
  };
  const clickCommunities = function() {
    communitiesSortBy = next(Object.keys(posts), 0, communitiesSortBy);
    GM_setValue('communitiesSortBy', communitiesSortBy);
    GM_registerMenuCommand(`Communities: 《${posts[communitiesSortBy]}》`, clickCommunities, { id: '2', autoClose: false, title: 'Click to change the communities sort type.' });
  };
  const clickSearch = function() {
    searchSortBy = next(Object.keys(posts), 3, searchSortBy);
    GM_setValue('searchSortBy', searchSortBy);
    GM_registerMenuCommand(`Search: 《${posts[searchSortBy]}》`, clickSearch, { id: '3', autoClose: false, title: 'Click to change the search sort type.' });
  };

  GM_registerMenuCommand(`Posts:《${posts[postsSortBy]}》`, clickPosts, { id: '0', autoClose: false, title: 'Click to change the posts sort type.' });
  GM_registerMenuCommand(`Comments:《${commentsSortBy}》`, clickComments, { id: '1', autoClose: false, title: 'Click to change the comments sort type.' });
  GM_registerMenuCommand(`Communities: 《${posts[communitiesSortBy]}》`, clickCommunities, { id: '2', autoClose: false, title: 'Click to change the communities sort type.' });
  GM_registerMenuCommand(`Search: 《${posts[searchSortBy]}》`, clickSearch, { id: '3', autoClose: false, title: 'Click to change the search sort type.' });

})();