Custom Logo picker for twitter.com

We've got birds! old birds, new birds, even pigeons! new competitors, dead competitors, federated competitors!

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name        Custom Logo picker for twitter.com
// @namespace   Itsnotlupus Industries
// @match       https://*.twitter.com/*
// @match       https://*.x.com/*
// @version     4.1
// @author      Itsnotlupus
// @license     MIT
// @description We've got birds! old birds, new birds, even pigeons! new competitors, dead competitors, federated competitors!
// @icon        https://abs.twimg.com/favicons/twitter.2.ico
// @require     https://greasyfork.org/scripts/468394-itsnotlupus-tiny-utilities/code/utils.js
// @require     https://greasyfork.org/scripts/471000-itsnotlupus-i18n-support/code/i18n.js
// @run-at      document-start
// @resource    old_twitter_favicon https://i.imgur.com/74OBSr6.png
// @resource    old_twitter_favicon_dot https://i.imgur.com/Yr0Gl7L.png
// @resource    older_twitter_logo https://i.imgur.com/NTT40TK.png
// @resource    older_twitter_favicon https://i.imgur.com/SYEM2RA.png
// @resource    older_twitter_favicon_dot https://i.imgur.com/VEnAuI0.png
// @resource    pigeon_logo https://i.imgur.com/CUspx8m.gif
// @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
// @grant       GM_addElement
// @grant       GM_xmlhttpRequest
// @grant       GM_registerMenuCommand
// ==/UserScript==

/* jshint esversion:11 */
/* jshint -W083 */
/* eslint no-return-assign:0, no-loop-func:0 */
/* global i18n, t, $, $$, $$$, observeDOM, untilDOM, sleep, until, crel, memoize, events, fastDebounce */
/* human  it's fine, it's fine, I promise. */

// utilities
const res = memoize(id => GM_getResourceURL(id)); // ensure we get the same URL for the same id. it helps with stuff.
const fav = memoize(id => GM_getResourceURL(id, false)); // favicons can't be blob: URIs. fine.
const mkNode = html => crel('div', { innerHTML: html}).firstChild;
const cssVars = o => Object.keys(o).forEach(k=>document.documentElement.style.setProperty(k,o[k]));
const testCSSHasSelector = () => { try { $`body:has(*)`; return true } catch { return false } }

// Localizable strings
const strings = {
  logo_menu_label: "Open/Close Logo Menu",
  toggle_branding_changes: "Enable/Disable Brand Changes"
};

// Boring Pre-Musk branding.
const brand = { site: 'Twitter', action: 'Tweet', item: 'Tweet', items: '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 = [
  `svg:not([class*="hidden"]):not([class*="twitter-x"]) path[d="${X_PATH}"]`,
  `svg:not([class*="hidden"]):not([class*="twitter-bird"]) 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", action: "eXecrate", item: 'eXecration', items: "eXecrations", reaction: "ReeXecrate", reactions: "ReeXecrations" },
    html: `<svg viewBox="0 0 24 24" aria-hidden="true" class="twitter-x"><path d="${X_PATH}"></path></svg>`,
    favicon: "https://abs.twimg.com/favicons/twitter.3.ico",
    faviconDot: "https://abs.twimg.com/favicons/twitter-pip.3.ico"
  },
  {
    // old twitter bird logo
    label: "Twitter",
    brand,
    html: `<svg viewBox="0 0 24 24" aria-hidden="true" class="twitter-bird"><path d="${BIRD_PATH}"></path></svg>`,
    favicon: "https://abs.twimg.com/favicons/twitter.2.ico",
    faviconDot: "https://abs.twimg.com/favicons/twitter-pip.2.ico",
  },
  { // even older twitter bird logo
    label: "Old Twitter",
    brand,
    html: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" class="twitter-bird" viewBox="0 0 380 380"><defs><linearGradient id="d"><stop offset="0%" stop-color="#157bab"></stop><stop offset="100%" stop-color="#599dd1"></stop></linearGradient><linearGradient xlink:href="#d" id="e" x1="0" x2="0" y1="0" y2="1" gradientTransform="rotate(154 1 1)" gradientUnits="objectBoundingBox"></linearGradient></defs><path d="M180 137c12-38 27-63 44-81 13-13 20-18 12-3l12-8c21-10 20-2 5 7 39-14 38 4-3 13 33 1 70 22 80 68 1 6 0 6 6 7 14 2 27 2 40-2-1 10-14 16-33 20-8 1-9 1 0 3 10 2 22 3 35 2-10 12-26 18-45 18-12 44-40 76-75 96-83 47-203 40-263-45 40 31 98 38 142-6-29 0-36-21-14-32-21-1-35-7-42-20-4-4-4-5 1-8 6-4 13-6 21-6-22-7-36-18-40-34-2-5-2-5 3-6l18-2c-18-11-28-24-31-38-2-14 0-10 10-6 45 17 90 36 117 63z" style="fill:url(#e)"></path></svg>`,
    favicon: fav`old_twitter_favicon`,
    faviconDot: fav`old_twitter_favicon_dot`
  },
  { // antediluvian twitter bird logo
    label: "Older Twitter",
    brand,
    html: `<img class="twitter-classic" src="${res`older_twitter_logo`}">`,
    logo: res`older_twitter_logo`,
    favicon: fav`older_twitter_favicon`,
    faviconDot: fav`older_twitter_favicon_dot`
  },
  // 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: "Pigeon",
    brand: { site: 'Pigeon', action: 'Coo', item: 'Coo', items: 'Coos', reaction: 'Grunt', reactions: 'Grunts' },
    html: `<img class="twitter-classic" src="${res`pigeon_logo`}">`,
    logo: res`pigeon_logo`,
    favicon: fav`pigeon_logo`
  },
  {
    label: "Bluesky",
    brand: { site: 'Bluesky', action: 'Skeet', item: 'Skeet', items: 'Skeets', reaction: 'Reskeet', reactions: 'Reskeets' },
    html: `<img class="twitter-classic" src="${res`bluesky_logo`}">`,
    logo: res`bluesky_logo`,
    favicon: fav`bluesky_favicon`
  },
  {
    label: "Threads",
    brand: { site: 'Threads', action: 'Post', item: 'Post', items: '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: fav`threads_favicon`
  },
  {
    label: "Mastodon",
    brand: { site: 'Mastodon', action: 'Toot', item: 'Toot', items: 'Toots', reaction: 'Retoot', reactions: 'Retoots' },
    html: `<svg xmlns="http://www.w3.org/2000/svg" class="twitter-bird" viewBox="0 0 75 79"><path fill="url(#a)" d="M74 17C73 9 65 2 57 1L36 0 19 1C11 2 3 8 1 17L0 30l1 19 2 12c2 7 9 13 16 16a43 43 0 0 0 26 0l6-2v-7c-5 2-10 2-15 2-9 0-12-4-12-6a19 19 0 0 1-1-5c5 2 10 2 15 2h4l15-1h1c7-2 15-7 16-19V17Z"></path><path fill="#fff" d="M61 27v21h-8V28c0-5-2-7-6-7s-6 3-6 8v11h-8V29c0-5-2-8-6-8s-5 2-5 7v20h-9V27c0-4 1-8 4-10 2-3 5-4 9-4s7 2 9 5l2 3 2-3c3-3 6-5 10-5s7 1 9 4c2 2 3 6 3 10Z"></path><defs><linearGradient id="a" x1="37.1" x2="37.1" y1="0" y2="79" gradientUnits="userSpaceOnUse"><stop stop-color="#6364FF"></stop><stop offset="1" stop-color="#563ACC"></stop></linearGradient></defs></svg>`,
    favicon: fav`mastodon_favicon`
  },
  {
    label: "Parler",
    brand: { site: 'Parler', action: 'Twat', item: 'Twat', items: '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: fav`parler_favicon`,
    dot: "#77f"
  },
  {
    label: "Truth Social",
    brand: { site: 'Truth Social', action: 'Truth', item: 'Truth', items: 'Truths', reaction: 'ReTruth', reactions: 'ReTruths' },
    html: `<img class="twitter-classic" src="${res`truthsocial_logo`}">`,
    logo: res`truthsocial_logo`,
    favicon: fav`truthsocial_logo`
  },
  {
    label: "Reddit",
    brand: { site: 'Reddit', action: 'Spez', item: 'Spez', items: '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: fav`reddit_favicon`,
    dot: "#77f"
  }
];

const MY_CSS = `
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-anchor {
  height: initial !important;
}
.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;
  pointer-events: none;
}

/* 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;
  pointer-events: none;
}

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

.hidden {
  display: none !important;
}
`;

class CustomLogoPicker {
  // Which logo is currently shown
  logo = {};
  // Whether silly branding should be shown
  branding = true;
  // A flag to pick a faster path where possible
  canUseHasSelector = testCSSHasSelector(); // :has() selector. useful, but not super well supported yet.

  constructor() {

    this.initI18N();

    this.applyState();
    this.applyFavicon();
    this.applyLogo();

    // insert styles. because this runs very early, document.head may be null.
    untilDOM("head").then(head=> head.prepend(crel('style', { textContent: MY_CSS })));

    // setup event listeners
    GM_addValueChangeListener("logo", this.stateChangeListener);
    GM_addValueChangeListener("branding", this.stateChangeListener);
    observeDOM(fastDebounce(this.DOMChangeListener));
    events({ keypress: this.keyPressListener });

    // start our threads
    this.runThemeWatcher();
    this.runDesktopDropdownWatcher();
    this.runMobileDropdownWatcher();
    this.runKeyboardShortcutDialogWatcher();
  }

  async initI18N() {
    await i18n.init({ strings });
    GM_registerMenuCommand(t`toggle_branding_changes`, () => {
      GM_setValue('branding', !GM_getValue("branding", true));
      this.DOMChangeListener();
    });
  }

  applyState() {
    const logoLabel = GM_getValue("logo")?.label ?? "Twitter";
    const l = LOGOS.find(logo => logo.label === logoLabel) ?? LOGOS[1];
    this.branding = GM_getValue("branding", true);
    this.applyBrand(this.logo.brand ?? brand, l.brand ?? brand);
    this.logo = l;
  }

  async applyFavicon () {
    const hasNotification = document.title[0] == "(";
    let icon = this.logo.favicon;
    if (hasNotification) {
      if (!this.logo.faviconDot) {
        // slap a dot on the favicon
        const img = await new Promise(r => crel('img', { src: icon, onload: e=>r(e.target) }));
        const { width, height } = img;
        const canvas = crel('canvas', { width, height });
        const ctx = canvas.getContext("2d");
        ctx.drawImage(img, 0, 0);
        ctx.fillStyle = this.logo.dot ?? "red";
        ctx.arc(width*3/4, height/4, width/5, 0, Math.PI*2);
        ctx.fill();
        this.logo.faviconDot = canvas.toDataURL();
      }
      icon = this.logo.faviconDot;
    }
    $$`link[rel="shortcut icon"],link[rel="icon"]`.forEach(link => {
      if (link.href !== icon) link.href = icon;
    });
  }

  applyLogo() {
    if (!this.logo.html) return;
    // initial sweep of legacy logos
    $$(legacyLogosSelector).forEach(path=> {
      const svg = path.closest`svg`;
      this.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|id)=".*?"/g,'') !== this.logo.html.replace(/ (class|style|id)=".*?"/g,'')) {
        this.replaceLegacyLogo(l, l.dataset.class);
      }
    });
    // special case primary logos on various other twitter pages
    const l = ($`.twtr-grid [aria-label$=" home"]` ?? $`.logo-title .logo` ?? $`[class$="_twitter-logo"]`)?.firstElementChild;
    if (l && !l.classList.contains('hidden') && l.outerHTML !== this.logo.html) {
      l.classList.add('hidden');
      if (this.logo.logo) {
        l.after(GM_addElement('img', { class: 'twitter-classic legacy-logo', src: this.logo.logo }))
      } else {
        const logoElt = mkNode(this.logo.html);
        logoElt.classList.add("legacy-logo");
        l.after(logoElt);
      }
    }
    // on Mobile, logos can disappear, and we need to keep up.
    $$`.legacy-logo`.forEach(l => {
      const previous = l.previousElementSibling;
      if (!previous || !previous.classList.contains("hidden")) {
        l.remove();
      }
    });
  }

  applyBrand(from, to) {
    // the silly branding variants only make sense in English, don't butcher other languages.
    const isEnglish = /^en([-_].*)?$/i.test(document.documentElement.lang);
    if (!this.branding) {
      // force default brand instead of our silly variants
      to = brand;
    }
    // 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 dict = { X: to.site, Post: to.action, Repost: to.reaction }; // X shenanigans to keep up with Elon's evolving non-sense.

    (isEnglish?["site","reactions","reaction","items","item","action"]:["site"]).forEach(key => {
      const word = from[key], betterWord = to[key];
      if (!word || !betterWord || word == betterWord) return;
      dict[word] = betterWord;
    });
    const keys = Object.keys(dict);
    if (keys.length==0) return;
    const regexp = new RegExp('\\b'+keys.join('\\b|\\b')+'\\b','g');

    // non-trivial Xpath evaluations are slow. matching CSS selectors, even with additional JS filters are often much faster.
    (this.canUseHasSelector
     ? [...$$`:is(div,span,title,a,b,button,h1,h2,p):not(:has(> *))`]
         .filter(e=>!e.closest`article[data-testid="tweet"],div[data-testid^="User"]`)
     : [...$$`div,span,title,a,b,button,h1,h2,p`]
         .filter(e=>!e.childElementCount && !e.closest`article[data-testid="tweet"],div[data-testid^="User"]`)
    )
      .filter(e => e.textContent.match(regexp))
      .forEach(elt => this.replaceBrandWord(elt, regexp, dict));
    $$$(`//span[text()="${keys.join('" or text()="')}"]`)
      .filter(e => !e.closest`[data-testid="tweetText"`)
      .forEach(elt => this.replaceBrandWord(elt, regexp, dict));
    $$(`[placeholder*="${keys.join('"],[placeholder*="')}"]`)
      .forEach(elt => {
        const newText = elt.placeholder.replace(regexp, w => dict[w]);
        if (elt.placeholder !== newText) elt.placeholder = newText;
      });
  }

  /** utility method to traverse a DOM subtree, look for text node matching a regexp, and replace matches with a dictionary */
  replaceBrandWord(elt, regexp, dict) {
    // update text without damaging the DOM tree.
    if (elt.childNodes.length>0) {
      elt.childNodes.forEach(e => this.replaceBrandWord(e, regexp, dict));
    } else {
      const newText = elt.textContent.replace(regexp, w => dict[w]);
      if (elt.textContent !== newText) elt.textContent = newText;
    }
  }

  /** utility method to replace a logo with the currently chosen logo (and carry some classes on the replacement logo) */
  replaceLegacyLogo(oldLogo, classes) {
    const logoElt = this.logo.logo ? GM_addElement('img', { class: 'twitter-classic', src: this.logo.logo }) : mkNode(this.logo.html);
    if (oldLogo.classList.contains("legacy-logo")) {
      // it's one of ours, safe to blow up
      oldLogo.replaceWith(logoElt);
    } else {
      // this may be a React node. hide but don't destroy.
      oldLogo.classList.add('hidden');
      oldLogo.after(logoElt);
    }
    logoElt.dataset.class = classes;
    logoElt.setAttribute('class', logoElt.getAttribute('class') + ' ' + classes + ' legacy-logo');
    logoElt.setAttribute('style', "max-height: initial;max-width:999px;padding: 0 10px");
  }

  /** show the logo dropdown anchored to the logo, in front of an invisible backdrop (to catch clicks), and hook pointer, key and focus events */
  openLogoDropDown(full, focus) {
    if (!$`.logo-dropdown-anchor`) return;
    let index = LOGOS.findIndex(l => this.logo.label === l.label);
    if (index==-1) index = 0;
    if (index >= LOGOS_CUTOFF) full = true;
    const disconnect = observeDOM(() => {
      const { bottom, left } = $`.logo-dropdown-anchor`.getBoundingClientRect();
      const dropdown = $`.logo-dropdown`;
      if (dropdown.style.top !== `${bottom}px`) dropdown.style.top = `${bottom}px`;
      if (dropdown.style.left !== `${left}px`) dropdown.style.left = `${left}px`;
    });
    const cleanupEventListener = events({
      keydown(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;
        }
      }
    });
    const backdrop = crel('div', {
      className: "logo-dropdown-backdrop",
      ariaHaspopup: "true",
      ariaControls: "menu",
      onclick() {
        disconnect();
        backdrop.remove();
        dropdown.remove();
        document.body.classList.remove('logo-dropdown-open');
        cleanupEventListener();
      }
    });
    const dropdown = crel('div', {
      className: "logo-dropdown",
      role: "menu",
      ariaLabel: 'Logo Picker',
      tabIndex: "-1",
    }, ...LOGOS.slice(0,full?void 0:LOGOS_CUTOFF).map(l => crel('div', {
      className: 'logo-dropdown-item',
      role: "menuitem",
      ariaLabel: l.label,
      title: l.label,
      tabIndex: "0",
      onclick() {
        backdrop.click();
      },
      onfocus: () => {
        this.applyBrand(this.logo.brand ?? brand, l.brand ?? brand);
        this.logo = l;
        GM_setValue("logo", {label: this.logo.label}); // don't store more than needed.
      },
      onkeypress(e) {
        if (e.code == "Enter" || e.code =="Space") {
          e.target.click();
          e.preventDefault();
        }
      }
    }, l.logo ? GM_addElement('img', { class: "twitter-classic", src: l.logo}): mkNode(l.html))));
    document.body.append(backdrop, dropdown);
    document.body.classList.add('logo-dropdown-open');
    if (focus) dropdown.childNodes[index].focus();
  }

  // # Event Listeners

  /** this fires where stored userscript data has changed (ie the logo or some settings have changed from another tab) */
  stateChangeListener = () => {
    this.applyState();
    this.DOMChangeListener();
  };

  /** this fires where the DOM has changed. it is also called manually in places where an update is needed. */
  DOMChangeListener = () => {
    this.applyFavicon();
    this.applyLogo();
    this.applyBrand(brand, this.logo.brand);
  };

  /** this fires when a key is pressed. this is used to react to the "Q" key being pressed. */
  keyPressListener = 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 {
        this.openLogoDropDown(e.shiftKey, true);
      }
    }
  };

  // # Threads - methods that never return, typically watching and reacting to some changes.

  /** detect a theme change (Twitter's white/dim/black, or even extensions like Dark Reader messing with it.) */
  async runThemeWatcher() {
    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;
      cssVars({
        "--twitter-bg-color": bgColor,
        "--twitter-icon-color": isDarkMode ? "#d6d9db" : "#242e36",
        "--icon-hover-bg": isDarkMode ? "rgba(239, 243, 244, 0.1)" : "rgba(15, 20, 25, 0.1)",
        "--dropdown-bg-color": isDarkMode ? "#111" : "#fff",
        "--dropdown-box-1": isDarkMode ? "rgba(255, 255, 255, 0.2)" : "rgba(101, 119, 134, 0.2)",
        "--dropdown-box-2": isDarkMode ? "rgba(255, 255, 255, 0.15)" : "rgba(101, 119, 134, 0.15)"
      })
    }
  }

  /** install a dropdown arrow next to the logo and hook some events */
  async runDesktopDropdownWatcher() {
    while (true) {
      const heading = await untilDOM(`header[role="banner"] h1[role="heading"]`);
      heading.append(crel('a', {
        className: "logo-dropdown-arrow logo-dropdown-anchor",
        textContent: '▾',
        onclick: e => this.openLogoDropDown(e.shiftKey)
      }));
      const logo = heading.firstChild;
      const cleanupEventListener = events({
        keypress: e => {
          if (e.code == 'Space' || e.code == 'Enter') {
            this.openLogoDropDown(e.shiftKey, true);
            e.preventDefault();
          }
        }
      }, logo);

      logo.tabIndex = "0";
      // wait until our dropdown gets wiped by React, and reapply our tweaks
      await untilDOM(()=>!$`.logo-dropdown-anchor`);
      cleanupEventListener();
    }
  }

  /** hook single tap and long press events on the mobile logo to show our dropdown */
  async runMobileDropdownWatcher() {
    while (true) {
      const logo = await untilDOM(() => $`[data-testid="TopNavBar"] :not([role="button"]) > div > svg`?.parentElement);
      logo.classList.add("logo-dropdown-anchor");
      let longPressTimer;
      const cleanupEventListeners = events({
        touchstart: () => {
          longPressTimer = setTimeout(() => this.openLogoDropDown(true), 500);
        },
        touchend(e) {
        clearTimeout(longPressTimer);
          if ($`.logo-dropdown`) {
            e.preventDefault();
            e.stopPropagation();
          }
        },
        click: e => {
          this.openLogoDropDown()
          e.stopImmediatePropagation();
        },
        contextmenu(e) { e.preventDefault() }
      }, logo);
      // wait until React wipes us out to reapply our tweaks
      await untilDOM(()=>!$`.logo-dropdown-anchor`);
      // `logo` isn't our node, clean up.
      cleanupEventListeners();
    }
  }

  /** wait for the Keyboard Shortcut list to show up, and inject our shortcut in it. */
  async runKeyboardShortcutDialogWatcher() {
    while (true) {
      // wait until something we see that has the shape of a keyboard shortcut modal, and grab the last "Actions" shortcut from it.
      // this unwiedly selector looks for the spot we want in desktop mode first, then in mobile device mode.
      const lastActionsRow = await untilDOM(`
        #layers [role="dialog"][aria-labelledby] [data-viewportview]>div:last-child>div:nth-child(2) [role="row"]:last-child,
        main[role="main"] div>div>div:last-child>div:nth-child(2) [role="row"]:last-child`);
      const label = lastActionsRow.innerText.split('\n')[0];
      if (label == t`logo_menu_label`) { // we already have our shortcut label shown. chill.
        await sleep(500);
        continue;
      }
      // clone, customize and insert our own action shortcut.
      const newRow = lastActionsRow.cloneNode(true);
      $('span', newRow).textContent = t`logo_menu_label`;
      $('[role="cell"]>div', newRow).textContent = 'q';
      lastActionsRow.after(newRow);
    }
  }
}

const main = new CustomLogoPicker();