Custom Logo picker for twitter.com

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

当前为 2023-07-31 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Custom Logo picker for twitter.com
  3. // @namespace Itsnotlupus Industries
  4. // @match https://*.twitter.com/*
  5. // @match https://*.x.com/*
  6. // @version 3.0.6
  7. // @author Itsnotlupus
  8. // @license MIT
  9. // @description We've got birds! old birds, new birds, even pigeons! new competitors, dead competitors, federated competitors!
  10. // @icon https://abs.twimg.com/favicons/twitter.2.ico
  11. // @require https://greasyfork.org/scripts/468394-itsnotlupus-tiny-utilities/code/utils.js
  12. // @require https://greasyfork.org/scripts/471000-itsnotlupus-i18n-support/code/i18n.js
  13. // @run-at document-start
  14. // @noframes
  15. // @resource old_twitter_favicon https://i.imgur.com/74OBSr6.png
  16. // @resource old_twitter_favicon_dot https://i.imgur.com/Yr0Gl7L.png
  17. // @resource older_twitter_logo https://i.imgur.com/NTT40TK.png
  18. // @resource older_twitter_favicon https://i.imgur.com/SYEM2RA.png
  19. // @resource older_twitter_favicon_dot https://i.imgur.com/VEnAuI0.png
  20. // @resource pigeon_logo https://i.imgur.com/CUspx8m.gif
  21. // @resource bluesky_logo https://i.imgur.com/fEq4EKr.png
  22. // @resource bluesky_favicon https://i.imgur.com/nCi5pTh.png
  23. // @resource threads_favicon https://i.imgur.com/Bv9o1px.png
  24. // @resource mastodon_favicon https://i.imgur.com/nKmYnXd.png
  25. // @resource parler_favicon https://i.imgur.com/hc5ccuN.png
  26. // @resource truthsocial_logo https://i.imgur.com/glC142w.png
  27. // @resource reddit_favicon https://i.imgur.com/oZcNyNR.png
  28. // @grant GM_setValue
  29. // @grant GM_getValue
  30. // @grant GM_addValueChangeListener
  31. // @grant GM_getResourceURL
  32. // @grant GM_addElement
  33. // @grant GM_xmlhttpRequest
  34. // @grant GM_registerMenuCommand
  35. // ==/UserScript==
  36. /* jshint esversion:11 */
  37. /* eslint no-return-assign:0, no-loop-func:0 */
  38. /* global i18n, t, $, $$, $$$, observeDOM, untilDOM, sleep, until, crel, memoize, events */
  39.  
  40. // Localizable strings
  41. const strings = {
  42. logo_menu_label: "Open/Close Logo Menu",
  43. toggle_branding_changes: "Enable/Disable Brand Changes"
  44. };
  45.  
  46. i18n.init({ strings }).then(() => {
  47. GM_registerMenuCommand(t`toggle_branding_changes`, () =>{
  48. GM_setValue('branding', !GM_getValue("branding", true));
  49. // emit a DOM mutation to trigger our observers and update everything.
  50. document.body.classList.toggle('logoChanged');
  51. })
  52. });
  53.  
  54. // Boring Pre-Musk branding.
  55. const brand = { site: 'Twitter', action: 'Tweet', actions: 'Tweets', reaction: 'Retweet', reactions: 'Retweets' };
  56. // Commonly found logos
  57. 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";
  58. 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";
  59. // Cheap way to identify uncorrected logos. Twitter currently uses a mix of both of those.
  60. const legacyLogosSelector = [
  61. `svg:not([class*="hidden"]) path[d="${X_PATH}"]`,
  62. `svg:not([class*="hidden"]) path[d="${BIRD_PATH}"]`
  63. ].join();
  64.  
  65. const res = memoize(id => GM_getResourceURL(id)); // ensure we get the same URL for the same id. it helps with stuff.
  66. const fav = memoize(id => GM_getResourceURL(id, false)); // favicons can't be blob: URIs. fine.
  67.  
  68. const LOGOS_CUTOFF = 4; // The first 4 logos are or were Twitter logos. The rest, well..
  69. const LOGOS = [
  70. {
  71. // visionary new X logo
  72. label: "𝕏",
  73. brand: { site: "X\u200b", action: "eXecrate", actions: "eXecrations", reaction: "ReeXecrate", reactions: "ReeXecrations" },
  74. html: `<svg viewBox="0 0 24 24" aria-hidden="true" class="twitter-x"><g><path d=" ${X_PATH}"></path></g></svg>`,
  75. favicon: "https://abs.twimg.com/favicons/twitter.3.ico",
  76. faviconDot: "https://abs.twimg.com/favicons/twitter-pip.3.ico"
  77. },
  78. {
  79. // old twitter bird logo
  80. label: "Twitter",
  81. brand,
  82. html: `<svg viewBox="0 0 24 24" aria-hidden="true" class="twitter-bird"><g><path d=" ${BIRD_PATH}"></path></g></svg>`,
  83. favicon: "https://abs.twimg.com/favicons/twitter.2.ico",
  84. faviconDot: "https://abs.twimg.com/favicons/twitter-pip.2.ico",
  85. },
  86. { // even older twitter bird logo
  87. label: "Old Twitter",
  88. brand,
  89. 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>`,
  90. favicon: fav`old_twitter_favicon`,
  91. faviconDot: fav`old_twitter_favicon_dot`
  92. },
  93. { // antediluvian twitter bird logo
  94. label: "Older Twitter",
  95. brand,
  96. html: `<img class="twitter-classic" src="${res`older_twitter_logo`}">`,
  97. logo: res`older_twitter_logo`,
  98. favicon: fav`older_twitter_favicon`,
  99. faviconDot: fav`older_twitter_favicon_dot`
  100. },
  101. // From the shallow end of nostalgia to the deep end.
  102. // Those logos are only shown in the dropdown if you press the `Shift` key while opening it.
  103. // Great care was taken in researching proper branding for each one.
  104. {
  105. label: "Pigeon",
  106. brand: { site: 'Pigeon', action: 'Coo', actions: 'Coos', reaction: 'Grunt', reactions: 'Grunts' },
  107. html: `<img class="twitter-classic" src="${res`pigeon_logo`}">`,
  108. logo: res`pigeon_logo`,
  109. favicon: fav`pigeon_logo`
  110. },
  111. {
  112. label: "Bluesky",
  113. brand: { site: 'Bluesky', action: 'Skeet', actions: 'Skeets', reaction: 'Reskeet', reactions: 'Reskeets' },
  114. html: `<img class="twitter-classic" src="${res`bluesky_logo`}">`,
  115. logo: res`bluesky_logo`,
  116. favicon: fav`bluesky_favicon`
  117. },
  118. {
  119. label: "Threads",
  120. brand: { site: 'Threads', action: 'Post', actions: 'Posts', reaction: 'Repost', reactions: 'Reposts' },
  121. 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>`,
  122. favicon: fav`threads_favicon`
  123. },
  124. {
  125. label: "Mastodon",
  126. brand: { site: 'Mastodon', action: 'Toot', actions: 'Toots', reaction: 'Retoot', reactions: 'Retoots' },
  127. 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>`,
  128. favicon: fav`mastodon_favicon`
  129. },
  130. {
  131. label: "Parler",
  132. brand: { site: 'Parler', action: 'Twat', actions: 'Twats', reaction: 'Echo', reactions: 'Echoes' },
  133. 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>`,
  134. favicon: fav`parler_favicon`,
  135. dot: "#77f"
  136. },
  137. {
  138. label: "Truth Social",
  139. brand: { site: 'Truth Social', action: 'Truth', actions: 'Truths', reaction: 'ReTruth', reactions: 'ReTruths' },
  140. html: `<img class="twitter-classic" src="${res`truthsocial_logo`}">`,
  141. logo: res`truthsocial_logo`,
  142. favicon: fav`truthsocial_logo`
  143. },
  144. {
  145. label: "Reddit",
  146. brand: { site: 'Reddit', action: 'Spez', actions: 'Spezz', reaction: 'Respez', reactions: 'Respezz' },
  147. 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>`,
  148. favicon: fav`reddit_favicon`,
  149. dot: "#77f"
  150. }
  151. ];
  152.  
  153. const WEB_MANIFEST= `
  154. {
  155. "background_color": "#ffffff",
  156. "categories": [
  157. "social",
  158. "news",
  159. "magazines"
  160. ],
  161. "description": "Get breaking news, politics, trending music, world events, sports scores, and the latest global news stories as they unfold - all with less data.",
  162. "display": "standalone",
  163. "gcm_sender_id": "49625052041",
  164. "gcm_user_visible_only": true,
  165. "icons": [
  166. {
  167. "src": "https://i.imgur.com/oZcNyNR.png",
  168. "sizes": "192x192",
  169. "type": "image/png"
  170. },
  171. {
  172. "src": "https://i.imgur.com/oZcNyNR.png",
  173. "sizes": "512x512",
  174. "type": "image/png"
  175. },
  176. {
  177. "purpose": "maskable",
  178. "src": "https://abs.twimg.com/responsive-web/client-web/icon-default-maskable.bacea37a.png",
  179. "sizes": "192x192",
  180. "type": "image/png"
  181. },
  182. {
  183. "purpose": "maskable",
  184. "src": "https://abs.twimg.com/responsive-web/client-web/icon-default-maskable-large.35928fda.png",
  185. "sizes": "512x512",
  186. "type": "image/png"
  187. }
  188. ],
  189. "name": "Twitterer",
  190. "screenshots": [
  191. {
  192. "src": "https://abs.twimg.com/responsive-web/client-web/twitter-lite-data-saver-marketing.6805986a.png",
  193. "sizes": "586x1041",
  194. "type": "image/png"
  195. },
  196. {
  197. "src": "https://abs.twimg.com/responsive-web/client-web/twitter-lite-explore-marketing.fd45b02a.png",
  198. "sizes": "586x1041",
  199. "type": "image/png"
  200. },
  201. {
  202. "src": "https://abs.twimg.com/responsive-web/client-web/twitter-lite-timeline-marketing.befcdb4a.png",
  203. "sizes": "586x1041",
  204. "type": "image/png"
  205. }
  206. ],
  207. "share_target": {
  208. "action": "compose/tweet",
  209. "enctype": "multipart/form-data",
  210. "method": "POST",
  211. "params": {
  212. "title": "title",
  213. "text": "text",
  214. "url": "url",
  215. "files": [
  216. {
  217. "name": "externalMedia",
  218. "accept": [
  219. "image/jpeg",
  220. "image/png",
  221. "image/gif",
  222. "video/quicktime",
  223. "video/mp4"
  224. ]
  225. }
  226. ]
  227. }
  228. },
  229. "shortcuts": [
  230. {
  231. "name": "New Tweet",
  232. "url": "/compose/tweet?utm_source=jumplist&utm_medium=shortcut",
  233. "icons": [
  234. {
  235. "src": "https://abs.twimg.com/responsive-web/client-web/icon-compose.1238442a.png",
  236. "type": "image/png",
  237. "sizes": "192x192"
  238. }
  239. ]
  240. },
  241. {
  242. "name": "Explore",
  243. "url": "/explore?utm_source=jumplist&utm_medium=shortcut",
  244. "icons": [
  245. {
  246. "src": "https://abs.twimg.com/responsive-web/client-web/icon-search-stroke.5f9aa88a.png",
  247. "type": "image/png",
  248. "sizes": "192x192"
  249. }
  250. ]
  251. },
  252. {
  253. "name": "Notifications",
  254. "url": "/notifications?utm_source=jumplist&utm_medium=shortcut",
  255. "icons": [
  256. {
  257. "src": "https://abs.twimg.com/responsive-web/client-web/icon-notifications-stroke.429602da.png",
  258. "type": "image/png",
  259. "sizes": "192x192"
  260. }
  261. ]
  262. },
  263. {
  264. "name": "Direct Messages",
  265. "url": "/messages?utm_source=jumplist&utm_medium=shortcut",
  266. "icons": [
  267. {
  268. "src": "https://abs.twimg.com/responsive-web/client-web/icon-messages-stroke.5f95edca.png",
  269. "type": "image/png",
  270. "sizes": "192x192"
  271. }
  272. ]
  273. }
  274. ],
  275. "short_name": "Twitterest",
  276. "start_url": "https://twitter.com/?utm_source=homescreen&utm_medium=shortcut",
  277. "theme_color": "#ffffff",
  278. "scope": "/",
  279. "android_package_name": "com.twitter.android",
  280. "prefer_related_applications": true,
  281. "related_applications": [
  282. {
  283. "id": "com.twitter.android",
  284. "platform": "chromeos_play",
  285. "url": "https://play.google.com/store/apps/details?id=com.twitter.android"
  286. }
  287. ],
  288. "launch_handler": {
  289. "route_to": "existing-client",
  290. "navigate_existing_client": "never"
  291. }
  292. }
  293. `;
  294.  
  295. // state management and backward compatibility sanity
  296. let logo = {};
  297. let branding = true;
  298. function applyState() {
  299. const logoLabel = GM_getValue("logo", { label: "Twitter"}).label ?? "Twitter";
  300. const l = LOGOS.find(logo => logo.label === logoLabel) ?? LOGOS[1];
  301. branding = GM_getValue("branding", true);
  302. applyBrand(logo.brand ?? brand, l.brand ?? brand);
  303. logo = l;
  304. }
  305. function stateChangeListener() {
  306. applyState();
  307. // emit a DOM mutation to trigger our observers and update everything.
  308. document.body.classList.toggle('logoChanged');
  309. }
  310. GM_addValueChangeListener("logo", stateChangeListener);
  311. GM_addValueChangeListener("branding", stateChangeListener);
  312. applyState();
  313.  
  314. // styles.
  315. untilDOM("head").then(head=>head.prepend(crel('style', { textContent: `
  316. header[role="banner"] h1[role="heading"] {
  317. flex-direction: row;
  318. }
  319. header[role="banner"] h1[role="heading"]:hover .logo-dropdown-arrow,
  320. header[role="banner"] h1[role="heading"] a:active + .logo-dropdown-arrow,
  321. body.logo-dropdown-open .logo-dropdown-arrow {
  322. opacity: 1;
  323. }
  324. .logo-dropdown-anchor {
  325. height: initial !important;
  326. }
  327. .logo-dropdown-arrow {
  328. opacity: 0;
  329. transition: all 250ms;
  330. width: 20px;
  331. height: 20px;
  332. line-height: 22px;
  333. margin-right: -20px;
  334. text-align: center;
  335. color: var(--twitter-icon-color);
  336. border-radius: 9px;
  337. background: var(--twitter-bg-color);
  338. }
  339. .logo-dropdown-arrow:hover {
  340. background: var(--icon-hover-bg);
  341. }
  342. .logo-dropdown-backdrop {
  343. position: fixed;
  344. inset: 0;
  345. background: rgba(0,0,0,0);
  346. }
  347. .logo-dropdown {
  348. position: fixed;
  349. width: 3rem;
  350. background: var(--twitter-bg-color);
  351. padding: 0.5em;
  352. border-radius: 5px;
  353. box-shadow: var(--dropdown-box-1) 0px 0px 15px, var(--dropdown-box-2) 0px 0px 3px 1px;
  354. }
  355. .logo-dropdown-item {
  356. cursor: pointer;
  357. height: 2rem;
  358. margin-top: 0.5em;
  359. margin-bottom: 0.5em;
  360. padding: 8px;
  361. transition: all 250ms;
  362. border-radius: 999px;
  363. }
  364. .logo-dropdown-item:hover, .logo-dropdown-item:focus {
  365. background: var(--icon-hover-bg);
  366. }
  367.  
  368. /* custom CSS for each logo */
  369. .twitter-x {
  370. height: 2rem;
  371. -ms-flex-positive: 1;
  372. -webkit-box-flex: 1;
  373. -webkit-flex-grow: 1;
  374. flex-grow: 1;
  375. color: var(--twitter-icon-color);
  376. -moz-user-select: none;
  377. -ms-user-select: none;
  378. -webkit-user-select: none;
  379. user-select: none;
  380. vertical-align: text-bottom;
  381. position: relative;
  382. max-width: 100%;
  383. fill: currentcolor;
  384. display: inline-block;
  385. }
  386.  
  387. /* can probably be used with any SVG logo */
  388. .twitter-bird {
  389. height: 2rem;
  390. color: rgba(29,155,240,1.00) !important;
  391. vertical-align: text-bottom;
  392. position: relative;
  393. max-width: 100%;
  394. fill: currentcolor;
  395. -moz-user-select: none;
  396. -ms-user-select: none;
  397. -webkit-user-select: none;
  398. user-select: none;
  399. display: inline-block;
  400. pointer-events: none;
  401. }
  402.  
  403. /* can probably be used with any bitmap logo */
  404. .twitter-classic {
  405. max-width: 2rem;
  406. vertical-align: text-bottom;
  407. position: relative;
  408. -moz-user-select: none;
  409. -ms-user-select: none;
  410. -webkit-user-select: none;
  411. user-select: none;
  412. display: inline-block;
  413. pointer-events: none;
  414. }
  415.  
  416. .threads-squiggly {
  417. height: 2rem;
  418. fill: var(--twitter-icon-color);
  419. }
  420.  
  421. .hidden {
  422. display: none !important;
  423. }
  424. `})));
  425.  
  426. // 0. avoid a flash of X favicon
  427. const dottedFavicons = {};
  428. async function overrideFavicon () {
  429. const hasNotification = document.title[0] == "(";
  430. let icon = logo.favicon;
  431. if (hasNotification) {
  432. if (logo.faviconDot) {
  433. icon = logo.faviconDot;
  434. } else {
  435. if (!dottedFavicons[icon]) {
  436. // slap a dot on the favicon
  437. const img = await new Promise(r => crel('img', { src: icon, onload: e=>r(e.target) }));
  438. const { width, height } = img;
  439. const canvas = crel('canvas', { width, height });
  440. const ctx = canvas.getContext("2d");
  441. ctx.drawImage(img, 0, 0);
  442. ctx.fillStyle = logo.dot ?? "red";
  443. ctx.arc(width*3/4, height/4, width/5, 0, Math.PI*2);
  444. ctx.fill();
  445. dottedFavicons[icon] = canvas.toDataURL();
  446. }
  447. icon = dottedFavicons[icon];
  448. }
  449. }
  450. $$`link[rel="shortcut icon"],link[rel="icon"]`.forEach(link => {
  451. if (link.href !== icon) link.href = icon;
  452. });
  453. }
  454. overrideFavicon();
  455.  
  456. // 1. early run to replace placeholder logo on app start.
  457. untilDOM("#placeholder").then(e=> {
  458. // tweak the loading logo right quick.
  459. if (logo.html) {
  460. const classes = e.firstChild.getAttribute("class");
  461. e.innerHTML = logo.html;
  462. e.firstChild.setAttribute('class', e.firstChild.getAttribute('class') + ' ' + classes);
  463. e.firstChild.setAttribute('style', "max-width: initial; height: initial");
  464. }
  465. });
  466.  
  467. // 2. watch for lightmode/darkmode changes and adjust (cheaply.)
  468. (async()=> {
  469. const bodyStyles = getComputedStyle(await until('body'));
  470. while (true) {
  471. var bgColor = await until((bg = bodyStyles.backgroundColor) => bg !== bgColor && bg);
  472. const isDarkMode = bgColor.replace(/[rgba( )]+/g,'').split(',').reduce((v,a)=>+a+v, 0) < 255;
  473.  
  474. document.body.style.setProperty("--twitter-bg-color", bgColor);
  475. document.body.style.setProperty("--twitter-icon-color", isDarkMode ? "rgba(214,217,219,1.00)" : "rgba(36,46,54,1.00)");
  476. document.body.style.setProperty("--icon-hover-bg", isDarkMode ? "rgba(239, 243, 244, 0.1)" : "rgba(15, 20, 25, 0.1)");
  477. document.body.style.setProperty("--dropdown-bg-color", isDarkMode ? "#111" : "#fff");
  478. document.body.style.setProperty("--dropdown-box-1", isDarkMode ? "rgba(255, 255, 255, 0.2)" : "rgba(101, 119, 134, 0.2)")
  479. document.body.style.setProperty("--dropdown-box-2", isDarkMode ? "rgba(255, 255, 255, 0.15)" : "rgba(101, 119, 134, 0.15)")
  480. }
  481. })();
  482.  
  483. // 3. DESKTOP: inject our logo picker dropdown, potentially several times.
  484. (async()=> {
  485. while (true) {
  486. const heading = await untilDOM(`header[role="banner"] h1[role="heading"]`);
  487. heading.append(crel('a', {
  488. className: "logo-dropdown-arrow logo-dropdown-anchor",
  489. textContent: '▾',
  490. onclick(e) { openLogoDropDown(e.shiftKey); }
  491. }));
  492. const logo = heading.firstChild;
  493. logo.addEventListener('keypress', e=> {
  494. if (e.code == 'Space' || e.code == 'Enter') {
  495. openLogoDropDown(e.shiftKey, true);
  496. e.preventDefault();
  497. }
  498. });
  499.  
  500. logo.tabIndex = "0";
  501. // wait until our dropdown gets wiped by React, and reapply our tweaks
  502. await untilDOM(()=>!$`.logo-dropdown-anchor`);
  503. }
  504. })();
  505.  
  506. // 𝜋. MOBILE: listen for tap or long tap on logo to bring up logo picker dropdown
  507. (async() => {
  508. while (true) {
  509. const logo = await untilDOM(() => $`[data-testid="TopNavBar"] :not([role="button"]) > div > svg`?.parentElement);
  510. logo.classList.add("logo-dropdown-anchor");
  511. let longPressTimer;
  512. const cleanupEventListeners = events({
  513. touchstart() {
  514. longPressTimer = setTimeout(() => openLogoDropDown(true), 500);
  515. },
  516. touchend(e) {
  517. clearTimeout(longPressTimer);
  518. if ($`.logo-dropdown`) {
  519. console.warn("FOO ASDFASD", e.target);
  520. e.preventDefault();
  521. e.stopPropagation();
  522. }
  523. },
  524. click(e) {
  525. openLogoDropDown()
  526. e.stopImmediatePropagation();
  527. },
  528. contextmenu(e) { e.preventDefault() }
  529. }, logo);
  530. // wait until React wipes us out to reapply our tweaks
  531. await untilDOM(()=>!$`.logo-dropdown-anchor`);
  532. // `logo` isn't our node, clean up.
  533. cleanupEventListeners();
  534. }
  535. })();;
  536.  
  537. // 3½. dropdown and logo selection logic
  538. function openLogoDropDown(full, focus) {
  539. if (!$`.logo-dropdown-anchor`) return;
  540. let index = LOGOS.findIndex(l => logo.label === l.label);
  541. if (index==-1) index = 0;
  542. if (index >= LOGOS_CUTOFF) full = true;
  543. const disconnect = observeDOM(() => {
  544. const { bottom, left } = $`.logo-dropdown-anchor`.getBoundingClientRect();
  545. const dropdown = $`.logo-dropdown`;
  546. if (dropdown.style.top !== `${bottom}px`) dropdown.style.top = `${bottom}px`;
  547. if (dropdown.style.left !== `${left}px`) dropdown.style.left = `${left}px`;
  548. });
  549. const backdrop = crel('div', {
  550. className: "logo-dropdown-backdrop",
  551. ariaHaspopup: "true",
  552. ariaControls: "menu",
  553. onclick() {
  554. disconnect();
  555. backdrop.remove();
  556. dropdown.remove();
  557. document.body.classList.remove('logo-dropdown-open');
  558. removeEventListener('keydown', dropdownKeyHandler, true);
  559. }
  560. });
  561. const dropdown = crel('div', {
  562. className: "logo-dropdown",
  563. role: "menu",
  564. ariaLabel: 'Logo Picker',
  565. tabIndex: "-1",
  566. }, ...LOGOS.slice(0,full?LOGOS.length:LOGOS_CUTOFF).map(l => crel('div', {
  567. className: 'logo-dropdown-item',
  568. role: "menuitem",
  569. ariaLabel: l.label,
  570. title: l.label,
  571. tabIndex: "0",
  572. ...(l.logo ? {} : { innerHTML: l.html }),
  573. onclick() {
  574. backdrop.click();
  575. },
  576. onfocus() {
  577. applyBrand(logo.brand ?? brand, l.brand ?? brand);
  578. logo = l;
  579. GM_setValue("logo", {label: logo.label}); // don't store more than needed.
  580. },
  581. onkeypress(e) {
  582. if (e.code == "Enter" || e.code =="Space") {
  583. e.target.click();
  584. e.preventDefault();
  585. }
  586. }
  587. }, l.logo ? GM_addElement('img', {
  588. class: "twitter-classic",
  589. src: l.logo
  590. }): '')));
  591. document.body.append(backdrop, dropdown);
  592. document.body.classList.add('logo-dropdown-open');
  593. if (focus) dropdown.childNodes[index].focus();
  594. addEventListener('keydown', dropdownKeyHandler, true);
  595. function dropdownKeyHandler(e) {
  596. const a = document.activeElement, active = a.parentElement == dropdown ? a : dropdown.childNodes[index];
  597. switch (e.code) {
  598. case 'Escape': backdrop.parentElement && backdrop.click(); e.preventDefault(); break;
  599. case 'ArrowUp': active.previousSibling?.focus(); e.preventDefault(); break;
  600. case 'ArrowDown': active.nextSibling?.focus(); e.preventDefault(); break;
  601. }
  602. }
  603. }
  604.  
  605. // 4. wait until the placeholder logo is out of the way, then replace the favicon and all logos aggressively.
  606. (async function applyLogo() {
  607. function replaceLegacyLogo(l, classes) {
  608. const logoElt = logo.logo ? GM_addElement('img', { class: 'twitter-classic', src: logo.logo }) : crel('div', { innerHTML: logo.html}).firstChild;
  609. if (l.classList.contains("legacy-logo")) {
  610. // it's one of ours, safe to blow up
  611. l.replaceWith(logoElt);
  612. } else {
  613. // this may be a React node. hide but don't destroy.
  614. l.classList.add('hidden');
  615. l.after(logoElt);
  616. }
  617. logoElt.dataset.class = classes;
  618. logoElt.setAttribute('class', logoElt.getAttribute('class') + ' ' + classes + ' legacy-logo');
  619. logoElt.setAttribute('style', "max-height: initial;max-width:999px;padding: 0 10px");
  620. }
  621. await untilDOM(()=>!$`#placeholder`);
  622. observeDOM(async() => {
  623. overrideFavicon();
  624. if (logo.html) {
  625. // initial sweep of legacy logos
  626. $$(legacyLogosSelector).forEach(path=> {
  627. const svg = path.closest`svg`;
  628. replaceLegacyLogo(svg, svg.getAttribute("class") ?? '');
  629. });
  630. // further updates of legacy logos when user picks another logo
  631. $$(".legacy-logo").forEach(l => {
  632. if (l.outerHTML.replace(/ (data-class|class|style|id)=".*?"/g,'') !== logo.html.replace(/ (class|style|id)=".*?"/g,'')) {
  633. replaceLegacyLogo(l, l.dataset.class);
  634. }
  635. });
  636. // special case the primary logo
  637. const l = ($`header[role="banner"] h1[role="heading"] a[href="/home"] > div` ?? $`header[role="banner"] h1[role="heading"] a[href="/"] > div` ?? $`.twtr-grid [aria-label$=" home"]` ?? $`.logo-title .logo` ?? $`[class$="_twitter-logo"]`)?.firstElementChild;
  638. if (l && !l.classList.contains('hidden') && l.outerHTML !== logo.html) {
  639. l.classList.add('hidden');
  640. if (logo.logo) {
  641. l.after(GM_addElement('img', { class: 'twitter-classic legacy-logo', src: logo.logo }))
  642. } else {
  643. const logoElt = crel('div', { innerHTML: logo.html}).firstChild;
  644. logoElt.classList.add("legacy-logo");
  645. l.after(logoElt);
  646. }
  647. }
  648. // on Mobile, the logo can disappear, and we need to mimic that.
  649. $$(".legacy-logo").forEach(l => {
  650. const previous = l.previousElementSibling;
  651. if (!previous || !previous.classList.contains("hidden")) {
  652. l.remove();
  653. }
  654. })
  655. }
  656. });
  657. })();
  658.  
  659. // 5. brand consistentcy enforcement
  660. function applyBrand(from, to) {
  661. // the silly branding variants only make sense in English, don't butcher other languages.
  662. const isEnglish = /^en([-_].*)?$/i.test(document.documentElement.lang);
  663. if (!branding) {
  664. // force default brand instead of our silly variants
  665. to = brand;
  666. }
  667. // nothing more confusing than a logo that doesn't match its copy. let's help!
  668. if (!to) return;
  669.  
  670. // avoid querying the DOM separately for each word. tweak things to find and replace all words in one shot.
  671. 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.
  672. (isEnglish?["site","reactions","reaction","actions","action"]:["site"]).forEach(key => {
  673. const word = from[key], betterWord = to[key];
  674. if (!word || !betterWord || word == betterWord) return;
  675. obj[word] = betterWord;
  676. });
  677. const keys = Object.keys(obj);
  678. if (keys.length==0) return;
  679. const regexp = new RegExp(keys.join('|'),'g');
  680. function replaceBrandWord(elt) {
  681. // update text without damaging the DOM tree.
  682. if (elt.childNodes.length>0) {
  683. elt.childNodes.forEach(replaceBrandWord);
  684. } else {
  685. elt.textContent = elt.textContent.replace(regexp, w => obj[w]);
  686. }
  687. }
  688. // non-trivial Xpath evaluations are slow. matching CSS selectors, even with additional filters are often much faster.
  689. [...$$`div,span,title,a,b,button,h1,h2,p`]
  690. .filter(e=>!e.childElementCount && !e.closest`article[data-testid="tweet"],div[data-testid^="User"]`)
  691. .filter(e => e.textContent.match(regexp))
  692. .forEach(replaceBrandWord);
  693. $$$(`//span[text()="${keys.join('" or text()="')}"]`)
  694. .filter(e => !e.closest`[data-testid="tweetText"`)
  695. .forEach(replaceBrandWord);
  696. $$(`[placeholder*="${keys.join('"],[placeholder*="')}"]`)
  697. .forEach(elt => elt.placeholder = elt.placeholder.replace(regexp, w => obj[w]));
  698. }
  699. observeDOM(()=>applyBrand(brand, logo.brand));
  700.  
  701. // 6. keyboard shortcut: 'Q' (add ourselves in the '?' dialog, and do the thing.)
  702. (async function keyboardShortcut() {
  703. while (true) {
  704. // wait until something we see that has the shape of a keyboard shortcut modal, and grab the last "Actions" shortcut from it.
  705. const lastActionsRow = await untilDOM('#layers [role="dialog"][aria-labelledby] [data-viewportview]>div:last-child>div:nth-child(2) [role="row"]:last-child');
  706. const label = lastActionsRow.innerText.split('\n')[0];
  707. if (label == t`logo_menu_label`) { // we already have our shortcut label shown. chill.
  708. await sleep(500);
  709. continue;
  710. }
  711. console.log(i18n);
  712. // clone, customize and insert a new action shortcut.
  713. const newRow = lastActionsRow.cloneNode(true);
  714. $('span', newRow).textContent = t`logo_menu_label`;
  715. $('[role="cell"]>div', newRow).textContent = 'q';
  716. lastActionsRow.after(newRow);
  717. }
  718. })();
  719. addEventListener('keypress', e => {
  720. const a = document.activeElement;
  721. if (a.contentEditable == 'true' || a.tagName == 'INPUT' || a.tagName == 'TEXTAREA') return;
  722. if (e.code == 'KeyQ') {
  723. if ($`.logo-dropdown`) {
  724. $`.logo-dropdown-backdrop`?.click();
  725. } else {
  726. openLogoDropDown(e.shiftKey, true);
  727. }
  728. }
  729. }, true);