Skip to content

Gander Social: Liked Posts Only

Here’s another Tampermonkey script for Gander Social. This is a modification of the script hides posts containing hashtags, but instead only shows posts you’ve liked.

// ==UserScript==
// @name         Gander Social - Liked Posts Only
// @namespace    https://shawnhooper.ca
// @version      1.2
// @description  Toggles the feed between all posts and only posts you've liked
// @author       Shawn M. Hooper
// @match        https://*.gander.social/*
// @icon         https://gander.social/favicon.png
// @run-at       document-end
// @grant        none
// ==/UserScript==
(function () {
  'use strict';

  // --- Config ---------------------------------------------------------------

  // Posts are <article> elements carrying the "post" class.
  const POST_SELECTOR = 'article.post';

  // A liked post renders its like button with this label.
  const UNLIKE_SELECTOR = 'button[aria-label="Unlike"]';

  // The toggle is inserted immediately before the settings block, so it sits to
  // its left in the same row.
  const ANCHOR_SELECTORS = [
    'div.hc__settings',
    '.hc__settings',
  ];

  // Used only if no settings icon can be found.
  const FALLBACK_SELECTOR = '.home__top';

  const STORAGE_KEY = 'gandermonkey.likedOnly';

  const DEBUG = false;

  // --- Hiding mechanism -----------------------------------------------------

  // A stylesheet + data attribute rather than an inline style: Gander is a Vue
  // app and manages the article's `style` attribute, so an inline display:none
  // gets wiped on re-patch. Vue leaves unknown data-* attributes alone.
  const HIDDEN_ATTR = 'data-gmlo-hidden';
  const BUTTON_ID = 'gmlo-toggle';

  const style = document.createElement('style');
  style.textContent = `
    [${HIDDEN_ATTR}="1"] { display: none !important; }
    #${BUTTON_ID} {
      display: inline-flex; align-items: center; gap: 4px; flex: none;
      margin: 0 10px 0 0; padding: 4px 8px; white-space: nowrap;
      font: inherit; font-size: 13px; font-weight: 600; line-height: 1;
      cursor: pointer; border: 0; border-radius: 999px;
      background: transparent; color: inherit;
    }
    #${BUTTON_ID}:hover { opacity: 0.7; }
    #${BUTTON_ID}[aria-pressed="true"] { color: #C30B0D; }
    #${BUTTON_ID} .gmlo-count { opacity: 0.7; font-weight: 500; font-size: 12px; }
  `;
  (document.head || document.documentElement).appendChild(style);

  // --- State ----------------------------------------------------------------

  let likedOnly = false;
  try {
    likedOnly = localStorage.getItem(STORAGE_KEY) === '1';
  } catch (e) {
    console.warn('[gandermonkey] could not read ' + STORAGE_KEY, e);
  }

  function persist() {
    try {
      localStorage.setItem(STORAGE_KEY, likedOnly ? '1' : '0');
    } catch (e) {
      console.warn('[gandermonkey] could not persist filter state (session only)', e);
    }
  }

  // --- Matching -------------------------------------------------------------

  // Fix quoted post likes
  function isLiked(post) {
    for (const btn of post.querySelectorAll(UNLIKE_SELECTOR)) {
      if (btn.closest(POST_SELECTOR) === post) return true;
    }
    return false;
  }

  // Only consider top-level posts, so hiding a parent doesn't fight with a
  // nested quote.
  function topLevelPosts() {
    return [...document.querySelectorAll(POST_SELECTOR)].filter(
      (p) => !p.parentElement?.closest(POST_SELECTOR)
    );
  }

  let shown = 0;
  let hidden = 0;


  function evaluate(post) {
    const hide = likedOnly && !isLiked(post);

    if (hide) {
      if (post.getAttribute(HIDDEN_ATTR) !== '1') post.setAttribute(HIDDEN_ATTR, '1');
      hidden++;
    } else {
      // Only ever unhide posts we hid ourselves.
      if (post.hasAttribute(HIDDEN_ATTR)) post.removeAttribute(HIDDEN_ATTR);
      shown++;
    }
  }

  let queued = false;
  function scheduleScan() {
    if (queued) return;
    queued = true;
    requestAnimationFrame(() => {
      queued = false;
      shown = 0;
      hidden = 0;
      topLevelPosts().forEach(evaluate);
      mountButton();
      updateButton();
      if (DEBUG) console.debug('[gandermonkey] shown', shown, 'hidden', hidden);
    });
  }

  // --- Toggle button --------------------------------------------------------

  let button = null;
  let warnedNoAnchor = false;

  function findAnchor() {
    for (const sel of ANCHOR_SELECTORS) {
      let el;
      try {
        el = document.querySelector(sel);
      } catch (e) {
        continue; // e.g. older engines rejecting the case-insensitive flag
      }
      if (el && !el.closest(POST_SELECTOR)) return el; // ignore anything inside a post
    }
    return null;
  }

  function mountButton() {
    const anchor = findAnchor();

    if (anchor) {
      // Already sitting immediately to the anchor's left — nothing to do.
      if (button && button.isConnected && anchor.previousElementSibling === button) return;
      if (!button) button = createButton();
      anchor.insertAdjacentElement('beforebegin', button);
      return;
    }

    const fallback = document.querySelector(FALLBACK_SELECTOR);
    if (!fallback) return;

    if (!warnedNoAnchor) {
      warnedNoAnchor = true;
      console.warn('[gandermonkey] .hc__settings not found; mounting the liked-only ' +
        'toggle in ' + FALLBACK_SELECTOR + ' instead. Adjust ANCHOR_SELECTORS.');
    }

    // Vue may re-render and drop our button; re-mount if so.
    if (button && button.isConnected && fallback.contains(button)) return;
    if (!button) button = createButton();
    fallback.appendChild(button);
  }

  function createButton() {
    const el = document.createElement('button');
    el.id = BUTTON_ID;
    el.type = 'button';
    el.title = 'Show only posts you\u2019ve liked';
    // Children are created once, here. updateButton() only ever touches their
    // textContent, and only when it actually changes — writing innerHTML on
    // every scan would generate childList mutations and spin the observer.
    el.appendChild(document.createElement('span')).className = 'gmlo-label';
    el.appendChild(document.createElement('span')).className = 'gmlo-count';
    el.addEventListener('click', () => setLikedOnly(!likedOnly));
    return el;
  }

  function setText(el, text) {
    if (el && el.textContent !== text) el.textContent = text;
  }

  function updateButton() {
    if (!button) return;
    const pressed = likedOnly ? 'true' : 'false';
    if (button.getAttribute('aria-pressed') !== pressed) {
      button.setAttribute('aria-pressed', pressed);
    }
    setText(
      button.querySelector('.gmlo-label'),
      (likedOnly ? '\u2665' : '\u2661') + ' Liked Posts Only'
    );
    setText(
      button.querySelector('.gmlo-count'),
      likedOnly ? `${shown}/${shown + hidden}` : ''
    );
  }

  function setLikedOnly(next) {
    likedOnly = !!next;
    persist();
    scheduleScan();
  }

  // --- Cross-tab sync -------------------------------------------------------

  window.addEventListener('storage', (e) => {
    if (e.key !== STORAGE_KEY) return;
    likedOnly = e.newValue === '1';
    scheduleScan();
  });

  // --- Observer -------------------------------------------------------------

  new MutationObserver((records) => {
    // Belt and braces against self-triggering: ignore batches that only
    // describe changes inside our own toggle button.
    if (button && records.every((r) => button === r.target || button.contains(r.target))) return;
    scheduleScan();
  }).observe(document.documentElement, {
    childList: true,
    subtree: true,
    attributes: true,
    attributeFilter: ['aria-label'],
  });

  scheduleScan();
})();

The script does not include posts where you liked a comment.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.