Branding? What branding? Logo picker for twitter.com

Adds a drop down by the site logo to let you pick logo alternatives.

目前为 2023-07-29 提交的版本。查看 最新版本

// ==UserScript==
// @name        Branding? What branding? Logo picker for twitter.com
// @namespace   Itsnotlupus Industries
// @match       https://*.twitter.com/*
// @version     2.3
// @author      Itsnotlupus
// @license     MIT
// @description Adds a drop down by the site logo to let you pick logo alternatives.
// @defaulticon https://abs.twimg.com/favicons/twitter.2.ico
// @require     https://greasyfork.org/scripts/468394-itsnotlupus-tiny-utilities/code/utils.js
// @run-at      document-start
// @resource    old_twitter_logo https://i.imgur.com/Y7UOiFn.png
// @resource    old_twitter_favicon https://i.imgur.com/BTc1fgP.png
// @resource    older_twitter_logo https://i.imgur.com/wuHjtYr.png
// @resource    older_twitter_favicon https://i.imgur.com/LgMguIS.png
// @resource    bluesky_logo https://i.imgur.com/fEq4EKr.png
// @resource    bluesky_favicon https://i.imgur.com/nCi5pTh.png
// @resource    threads_favicon https://i.imgur.com/Bv9o1px.png
// @resource    mastodon_favicon https://i.imgur.com/nKmYnXd.png
// @resource    parler_favicon https://i.imgur.com/hc5ccuN.png
// @resource    truthsocial_logo https://i.imgur.com/glC142w.png
// @resource    reddit_favicon https://i.imgur.com/oZcNyNR.png
// @grant       GM_setValue
// @grant       GM_getValue
// @grant       GM_addValueChangeListener
// @grant       GM_getResourceURL
// ==/UserScript==
/* jshint esversion:11 */

// Boring Pre-Musk branding. Do not use.
const brand = { site: 'Twitter', action: 'Tweet', actions: 'Tweets', reaction: 'Retweet', reactions: 'Retweets' };
// Commonly found logos
const X_PATH = "M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z";
const BIRD_PATH = "M23.643 4.937c-.835.37-1.732.62-2.675.733.962-.576 1.7-1.49 2.048-2.578-.9.534-1.897.922-2.958 1.13-.85-.904-2.06-1.47-3.4-1.47-2.572 0-4.658 2.086-4.658 4.66 0 .364.042.718.12 1.06-3.873-.195-7.304-2.05-9.602-4.868-.4.69-.63 1.49-.63 2.342 0 1.616.823 3.043 2.072 3.878-.764-.025-1.482-.234-2.11-.583v.06c0 2.257 1.605 4.14 3.737 4.568-.392.106-.803.162-1.227.162-.3 0-.593-.028-.877-.082.593 1.85 2.313 3.198 4.352 3.234-1.595 1.25-3.604 1.995-5.786 1.995-.376 0-.747-.022-1.112-.065 2.062 1.323 4.51 2.093 7.14 2.093 8.57 0 13.255-7.098 13.255-13.254 0-.2-.005-.402-.014-.602.91-.658 1.7-1.477 2.323-2.41z";
// Cheap way to identify uncorrected logos. Twitter currently uses a mix of both of those.
const legacyLogosSelector = [
  `path[d="${X_PATH}"]`,
  `path[d="${BIRD_PATH}"]`
].join();

const LOGOS_CUTOFF = 4; // The first 4 logos are or were Twitter logos. The rest, well..
const LOGOS = [
  {
    // visionary new X logo
    label: "𝕏",
    brand: { site: "X\u200b", action: "eXecrate", actions: "eXecrations", reaction: "ReeXecrate", reactions: "ReeXecrations" },
    html: `<svg viewBox="0 0 24 24" aria-hidden="true" class="twitter-x"><g><path d=" ${X_PATH}"></path></g></svg>`,
    favicon: "https://abs.twimg.com/favicons/twitter.3.ico"
  },
  {
    // old twitter bird logo
    label: "Twitter",
    brand: { site: 'Twitter', action: 'Tweet', actions: 'Tweets', reaction: 'Retweet', reactions: 'Retweets' },
    html: `<svg viewBox="0 0 24 24" aria-hidden="true" class="twitter-bird"><g><path d=" ${BIRD_PATH}"></path></g></svg>`,
    favicon: "https://abs.twimg.com/favicons/twitter.2.ico"
  },
  { // even older twitter bird logo
    label: "Old Twitter",
    brand: { site: 'Twitter', action: 'Tweet', actions: 'Tweets', reaction: 'Retweet', reactions: 'Retweets' },
    html: `<img class="twitter-classic" src="${GM_getResourceURL('old_twitter_logo')}">`,
    favicon: GM_getResourceURL("old_twitter_favicon")
  },
  { // antediluvian twitter bird logo
    label: "Older Twitter",
    brand: { site: 'Twitter', action: 'Tweet', actions: 'Tweets', reaction: 'Retweet', reactions: 'Retweets' },
    html: `<img class="twitter-classic" src="${GM_getResourceURL('older_twitter_logo')}">`,
    favicon: GM_getResourceURL("older_twitter_favicon")
  },
  // From the shallow end of nostalgia to the deep end.
  // Those logos are only shown in the dropdown if you press the `Shift` key while opening it.
  // Great care was taken in researching proper branding for each one.
  {
    label: "Bluesky",
    brand: { site: 'Bluesky', action: 'Skeet', actions: 'Skeets', reaction: 'Reskeet', reactions: 'Reskeets' },
    html: `<img class="twitter-classic" src="${GM_getResourceURL('bluesky_logo')}">`,
    favicon: GM_getResourceURL("bluesky_favicon")
  },
  {
    label: "Threads",
    brand: { site: 'Threads', action: 'Post', actions: 'Posts', reaction: 'Repost', reactions: 'Reposts' },
    html: `<svg xmlns="http://www.w3.org/2000/svg" class="threads-squiggly" viewBox="0 0 192 192"><path d="m142 89-3-1c-1-27-16-43-41-43h-1c-15 0-27 6-35 18l14 9a24 24 0 0 1 21-10c9 0 15 2 19 7 3 3 5 8 6 14-7-1-15-2-24-1-24 1-39 15-38 34 1 10 5 19 14 24 7 5 16 7 25 6 13 0 23-5 29-14 6-6 9-15 10-26 6 4 11 9 13 14 4 10 4 26-8 39-12 11-25 16-46 16-23 0-40-7-51-22a93 93 0 0 1-16-57c0-25 5-44 16-57 11-15 28-22 51-22s41 7 52 22c6 7 10 16 13 26l16-4c-3-13-9-24-16-33A80 80 0 0 0 97 0C69 0 47 10 33 28 20 44 13 67 13 96s7 52 20 68c14 18 36 28 64 28 25 0 43-7 57-21a50 50 0 0 0-12-82Zm-44 41c-10 0-21-5-21-15-1-7 5-15 22-16a101 101 0 0 1 23 1c-2 25-13 29-24 30Z"></path></svg>`,
    favicon: GM_getResourceURL("threads_favicon")
  },
  {
    label: "Mastodon",
    brand: { site: 'Mastodon', action: 'Toot', actions: 'Toots', reaction: 'Retoot', reactions: 'Retoots' },
    html: `<svg xmlns="http://www.w3.org/2000/svg" class="twitter-bird" viewBox="0 0 66 66"><path fill="url(#a)" d="M61 14C60 7 54 2 47 1L30 0 16 1C9 2 2 7 1 14L0 25l1 15 1 10c2 6 8 11 14 13a36 36 0 0 0 21 1l5-2v-6l-13 2c-7 0-9-4-10-5a15 15 0 0 1 0-4l12 1h3l13-1c7-1 13-5 14-15V14Z"></path><path fill="#fff" d="M12 18c0-2 2-3 4-3s4 1 4 3-2 4-4 4-4-2-4-4Z"></path><defs><linearGradient id="a" x1="30.5" x2="30.5" y1="0" y2="65" gradientUnits="userSpaceOnUse"><stop stop-color="#6364FF"></stop><stop offset="1" stop-color="#563ACC"></stop></linearGradient></defs></svg>`,
    favicon: GM_getResourceURL("mastodon_favicon")
  },
  {
    label: "Parler",
    brand: { site: 'Parler', action: 'Twat', actions: 'Twats', reaction: 'Echo', reactions: 'Echoes' },
    html: `<svg xmlns="http://www.w3.org/2000/svg" class="twitter-bird" viewBox="0 0 500 500"><g clip-path="url(#c)"><path fill="url(#b)" d="M200 300v-50h100a50 50 0 0 0 0-100H0C0 67 67 0 150 0h150a200 200 0 1 1 0 400c-55 0-100-45-100-100Zm-50 50V200C67 200 0 267 0 350v150c83 0 150-67 150-150Z"></path></g><defs><linearGradient id="b" x1="0" x2="500" y1="0" y2="500" gradientUnits="userSpaceOnUse"><stop stop-color="#892E5E"></stop><stop offset="1" stop-color="#E90039"></stop></linearGradient><clipPath id="c"><path fill="#fff" d="M0 0h1646v500H0z"></path></clipPath></defs></svg>`,
    favicon: GM_getResourceURL("parler_favicon")
  },
  {
    label: "Truth Social",
    brand: { site: 'Truth Social', action: 'Truth', actions: 'Truths', reaction: 'ReTruth', reactions: 'ReTruths' },
    html: `<img class="twitter-classic" src="${GM_getResourceURL('truthsocial_logo')}">`,
    favicon: GM_getResourceURL('truthsocial_logo')
  },
  {
    label: "Reddit",
    brand: { site: 'Reddit', action: 'Spez', actions: 'Spezz', reaction: 'Respez', reactions: 'Respezz' },
    html: `<svg class="twitter-bird" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 810 810"><circle cx="406.6" cy="405.6" r="402.3" fill="#ff4500"></circle><path d="M675 406a59 59 0 0 0-99-41c-46-31-100-48-155-49l26-126 86 18a40 40 0 1 0 5-24l-98-20c-7-1-14 3-15 10l-30 139c-56 1-111 18-157 50a59 59 0 1 0-65 96v18c0 90 105 163 235 163s234-73 234-163v-18c21-10 33-31 33-53zm-402 40a40 40 0 0 1 80 0 40 40 0 0 1-80 0zm233 111c-28 21-63 32-99 31-36 1-71-10-100-31-3-5-3-12 2-16 4-3 10-3 14 0 24 18 54 27 84 26 30 1 59-7 84-25 4-4 11-4 16 0s4 12-1 16v-1zm-7-69a40 40 0 0 1 0-81c22 0 40 18 40 40 1 23-16 41-38 42h-2v-1z" fill="#fff"></path></svg>`,
    favicon: GM_getResourceURL('reddit_favicon')
  }
];

// state management and backward compatibility sanity
let logo = {};
function applySavedLogo() {
  const logoLabel = GM_getValue("logo", { label: "Twitter"}).label ?? "Twitter";
  const l = LOGOS.find(logo => logo.label === logoLabel) ?? LOGOS[1];
  applyBrand(logo.brand ?? brand, l.brand ?? brand);
  logo = l;
}
GM_addValueChangeListener("logo", () => {
  applySavedLogo();
  // emit a DOM mutation to trigger our observers and update everything.
  document.body.classList.toggle('logoChanged');
});
applySavedLogo();

// styles.
document.head.prepend(crel('style', { textContent: `
header[role="banner"] h1[role="heading"] {
  flex-direction: row;
}
header[role="banner"] h1[role="heading"]:hover .logo-dropdown-arrow,
header[role="banner"] h1[role="heading"] a:active + .logo-dropdown-arrow,
body.logo-dropdown-open .logo-dropdown-arrow {
 opacity: 1;
}
.logo-dropdown-arrow {
  opacity: 0;
  transition: all 250ms;
  width: 20px;
  height: 20px;
  line-height: 22px;
  margin-right: -20px;
  text-align: center;
  color: var(--twitter-icon-color);
  border-radius: 9px;
  background: var(--twitter-bg-color);
}
.logo-dropdown-arrow:hover {
  background: var(--icon-hover-bg);
}
.logo-dropdown-backdrop {
  position: fixed;
  inset: 0;
  background: rgba(0,0,0,0);
}
.logo-dropdown {
  position: fixed;
  width: 3rem;
  background: var(--twitter-bg-color);
  padding: 0.5em;
  border-radius: 5px;
  box-shadow: var(--dropdown-box-1) 0px 0px 15px, var(--dropdown-box-2) 0px 0px 3px 1px;
}
.logo-dropdown-item {
  cursor: pointer;
  height: 2rem;
  margin-top: 0.5em;
  margin-bottom: 0.5em;
  padding: 8px;
  transition: all 250ms;
  border-radius: 999px;
}
.logo-dropdown-item:hover, .logo-dropdown-item:focus {
  background: var(--icon-hover-bg);
}

/* custom CSS for each logo */
.twitter-x {
  height: 2rem;
  -ms-flex-positive: 1;
  -webkit-box-flex: 1;
  -webkit-flex-grow: 1;
  flex-grow: 1;
  color: var(--twitter-icon-color);
  -moz-user-select: none;
  -ms-user-select: none;
  -webkit-user-select: none;
  user-select: none;
  vertical-align: text-bottom;
  position: relative;
  max-width: 100%;
  fill: currentcolor;
  display: inline-block;
}

/* can probably be used with any SVG logo */
.twitter-bird {
  height: 2rem;
  color: rgba(29,155,240,1.00) !important;
  vertical-align: text-bottom;
  position: relative;
  max-width: 100%;
  fill: currentcolor;
  -moz-user-select: none;
  -ms-user-select: none;
  -webkit-user-select: none;
  user-select: none;
  display: inline-block;
}

/* can probably be used with any bitmap logo */
.twitter-classic {
  max-width: 2rem;
  vertical-align: text-bottom;
  position: relative;
  -moz-user-select: none;
  -ms-user-select: none;
  -webkit-user-select: none;
  user-select: none;
  display: inline-block;
}

.threads-squiggly {
  height: 2rem;
  fill: var(--twitter-icon-color);
}

`}));

// 0. avoid a flash of X favicon
function overrideFavicon () {
  if (logo.favicon) {
    $$`link[rel="shortcut icon"],link[rel="icon"]`.forEach(link => {
      if (link.href !== logo.favicon) link.href = logo.favicon;
    });
  }
}
overrideFavicon();

// 1. early run to replace placeholder logo on app start.
untilDOM("#placeholder").then(e=> {
  // tweak the loading logo right quick.
  if (logo.html) {
    const classes = e.firstChild.getAttribute("class");
    e.innerHTML = logo.html;
    e.firstChild.setAttribute('class', e.firstChild.getAttribute('class') + ' ' + classes);
    e.firstChild.setAttribute('style', "max-width: initial; height: initial");
  }
});

// 2. watch for lightmode/darkmode changes and adjust (cheaply.)
(async()=> {
  const bodyStyles = getComputedStyle(await until('body'));
  while (true) {
    var bgColor = await until((bg = bodyStyles.backgroundColor) => bg !== bgColor && bg);
    const isDarkMode = bgColor.replace(/[rgba( )]+/g,'').split(',').reduce((v,a)=>+a+v, 0) < 255;

    document.body.style.setProperty("--twitter-bg-color", bgColor);
    document.body.style.setProperty("--twitter-icon-color", isDarkMode ? "rgba(214,217,219,1.00)" :  "rgba(36,46,54,1.00)");
    document.body.style.setProperty("--icon-hover-bg", isDarkMode ? "rgba(239, 243, 244, 0.1)" : "rgba(15, 20, 25, 0.1)");
    document.body.style.setProperty("--dropdown-bg-color", isDarkMode ? "#111" :  "#fff");
    document.body.style.setProperty("--dropdown-box-1", isDarkMode ? "rgba(255, 255, 255, 0.2)" : "rgba(101, 119, 134, 0.2)")
    document.body.style.setProperty("--dropdown-box-2", isDarkMode ? "rgba(255, 255, 255, 0.15)" : "rgba(101, 119, 134, 0.15)")
  }
})();

// 3. inject our logo picker dropdown, potentially several times.
(async()=> {
  while (true) {
    const heading = await untilDOM(`header[role="banner"] h1[role="heading"]`);
    heading.append(crel('a', {
      className: "logo-dropdown-arrow",
      textContent: '▾',
      onclick(e) { openLogoDropDown(e.shiftKey); }
    }));
    const logo = heading.firstChild;
    logo.addEventListener('keypress', e=> {
      if (e.code == 'Space' || e.code == 'Enter') {
        openLogoDropDown(e.shiftKey, true);
        e.preventDefault();
      }
    });
    logo.tabIndex = "0";
    // wait until our dropdown gets wiped by React, and reapply our tweaks
    await untilDOM(()=>!$`.logo-dropdown-arrow`);
  }
})();

// 3½. dropdown and logo selection logic
function openLogoDropDown(full, focus) {
  if (!$`.logo-dropdown-arrow`) return;
  const { bottom, left } = $`.logo-dropdown-arrow`.getBoundingClientRect();
  let index = LOGOS.findIndex(l => logo.label === l.label);
  if (index==-1) index = 0;
  if (index >= LOGOS_CUTOFF) full = true;
  const backdrop = crel('div', {
    className: "logo-dropdown-backdrop",
    ariaHaspopup: "true",
    ariaControls: "menu",
    onclick() {
      backdrop.remove();
      dropdown.remove();
      document.body.classList.remove('logo-dropdown-open');
      removeEventListener('keydown', dropdownKeyHandler, true);
    }
  });
  const dropdown = crel('div', {
    className: "logo-dropdown",
    role: "menu",
    ariaLabel: 'Logo Picker',
    tabIndex: "-1",
    style: `top: ${bottom}px; left: ${left}px;`
  }, ...LOGOS.slice(0,full?LOGOS.length:LOGOS_CUTOFF).map(l => crel('div', {
    className: 'logo-dropdown-item',
    role: "menuitem",
    ariaLabel: l.label,
    title: l.label,
    tabIndex: "0",
    innerHTML: l.html,
    onclick() {
      backdrop.click();
    },
    onfocus() {
      applyBrand(logo.brand ?? brand, l.brand ?? brand);
      logo = l;
      GM_setValue("logo", {label: logo.label}); // don't store more than needed.
    },
    onkeypress(e) {
      if (e.code == "Enter" || e.code =="Space") {
        e.target.click();
        e.preventDefault();
      }
    }
  })));
  document.body.append(backdrop, dropdown);
  document.body.classList.add('logo-dropdown-open');
  if (focus) dropdown.childNodes[index].focus();
  addEventListener('keydown', dropdownKeyHandler, true);
  function dropdownKeyHandler(e) {
    const a = document.activeElement, active = a.parentElement == dropdown ? a : dropdown.childNodes[index];
    switch (e.code) {
      case 'Escape': backdrop.parentElement && backdrop.click(); e.preventDefault(); break;
      case 'ArrowUp': active.previousSibling?.focus(); e.preventDefault(); break;
      case 'ArrowDown': active.nextSibling?.focus(); e.preventDefault(); break;
    }
  }
}

// 4. wait until the placeholder logo is out of the way, then replace the favicon and all logos aggressively.
(async function applyLogo() {
  function replaceLegacyLogo(l, classes) {
    const logoElt = crel('div', { innerHTML: logo.html}).firstChild;
    l.replaceWith(logoElt);
    logoElt.dataset.class = classes;
    logoElt.setAttribute('class', logoElt.getAttribute('class') + ' ' + classes + ' ' + 'legacy-logo');
    logoElt.setAttribute('style', "max-width: initial");
  }
  await untilDOM(()=>!$`#placeholder`);
  observeDOM(async() => {
    overrideFavicon();
    if (logo.html) {
      // initial sweep of legacy logos
      $$(legacyLogosSelector).forEach(path=> {
        const svg = path.closest`svg`;
        replaceLegacyLogo(svg, svg.getAttribute("class") ?? '');
      });
      // further updates of legacy logos when user picks another logo
      $$(".legacy-logo").forEach(l => {
        if (l.outerHTML.replace(/ (data-class|class|style)=".*?"/g,'') !== logo.html.replace(/ (class|style)=".*?"/g,'')) {
          replaceLegacyLogo(l, l.dataset.class);
        }
      });
      // special case the primary logo
      const logoParent = $`header[role="banner"] h1[role="heading"] a[href="/home"] > div` ?? $`header[role="banner"] h1[role="heading"] a[href="/"] > div`  ?? $`[class$="_twitter-logo"]` ?? $`.twtr-grid [aria-label$=" home"]` ?? $`.logo-title .logo`;
      if (logoParent && logoParent.innerHTML !== logo.html) {
        logoParent.innerHTML = logo.html;
      }
    }
  });
})();

// 5. brand consistentcy enforcement
function applyBrand(from, to) {
  // this part only makes sense in English, don't butcher other languages.
  const { lang } = document.documentElement;
  if (!/^en([-_].*)?$/.test(lang)) return;
  // nothing more confusing than a logo that doesn't match its copy. let's help!
  if (!to) return;

  // avoid querying the DOM separately for each word. tweak things to find and replace all words in one shot.
  const obj = to.site == LOGOS[0].brand.site ? {} : { "\\bX\\b" : true, X: to.site }; // X shenanigans to keep up with Elon's evolving non-sense.
  ["site","reactions","reaction","actions","action"].forEach(key => {
    const word = from[key], betterWord = to[key];
    if (!word || !betterWord || word == betterWord) return;
    obj[word] = betterWord;
  });
  const keys = Object.keys(obj);
  if (keys.length==0) return;
  const regexp = new RegExp(keys.join('|'),'g');
  function replaceBrandWord(elt) {
    // update text without damaging the DOM tree.
    if (elt.childNodes.length>0) {
      elt.childNodes.forEach(replaceBrandWord);
    } else {
      elt.textContent = elt.textContent.replace(regexp, w => obj[w]);
    }
  }
  // non-trivial Xpath evaluations are slow. matching CSS selectors, even with additional filters are often much faster.
  [...$$`:is(div,span,title,a,b,button,h1,h2,p):not(:has(> *))`]
    .filter(e=>!e.closest`article[data-testid="tweet"],div[data-testid^="User"]`)
    .filter(e => e.textContent.match(regexp))
    .forEach(replaceBrandWord);
  $$$(`//span[text()="${keys.join('" or text()="')}"]`)
    .filter(e => !e.closest`[data-testid="tweetText"`)
    .forEach(replaceBrandWord);
  $$(`[placeholder*="${keys.join('"],[placeholder*="')}"]`)
    .forEach(elt => elt.placeholder = elt.placeholder.replace(regexp, w => obj[w]));
}
observeDOM(()=>applyBrand(brand, logo.brand));

// 6. keyboard shortcut: 'Q' (add ourselves in the '?' dialog, and do the thing.)
const LOGO_MENU_LABEL = "Open/Close Logo Menu";
(async function keyboardShortcut() {
  while (true) {
    // wait until something we see that has the shape of a keyboard shortcut modal, and grab the last "Actions" shortcut from it.
    const lastActionsRow = await untilDOM('#layers [role="dialog"][aria-labelledby] [data-viewportview]>div:last-child>div:nth-child(2) [role="row"]:last-child');
    const label = lastActionsRow.innerText.split('\n')[0];
    if (label == LOGO_MENU_LABEL) { // we already have our shortcut label shown. chill.
      await sleep(500);
      continue;
    }
    // clone, customize and insert a new action shortcut.
    const newRow = lastActionsRow.cloneNode(true);
    $('span', newRow).textContent = LOGO_MENU_LABEL;
    $('[role="cell"]>div', newRow).textContent = 'q';
    lastActionsRow.after(newRow);
  }
})();
addEventListener('keypress', e => {
  const a = document.activeElement;
  if (a.contentEditable == 'true' || a.tagName == 'INPUT' || a.tagName == 'TEXTAREA') return;
  if (e.code == 'KeyQ') {
    if ($`.logo-dropdown`) {
      $`.logo-dropdown-backdrop`?.click();
    } else {
      openLogoDropDown(e.shiftKey, true);
    }
  }
}, true);