Tweak New Twitter

Reduce "engagement" and tone down some of New Twitter's UI

目前為 2020-04-04 提交的版本,檢視 最新版本

  1. // ==UserScript==
  2. // @name Tweak New Twitter
  3. // @description Reduce "engagement" and tone down some of New Twitter's UI
  4. // @namespace https://github.com/insin/tweak-new-twitter/
  5. // @match https://twitter.com/*
  6. // @version 11
  7. // ==/UserScript==
  8.  
  9. //#region Config & variables
  10. /**
  11. * Default config enables all features.
  12. *
  13. * You'll need to edit the config object manually for now if you're using this
  14. * as a user script.
  15. */
  16. let config = {
  17. alwaysUseLatestTweets: true,
  18. hideBookmarksNav: true,
  19. hideExploreNav: true,
  20. hideListsNav: true,
  21. hideSidebarContent: true,
  22. navBaseFontSize: true,
  23. /** @type {'separate'|'hide'|'ignore'} */
  24. retweets: 'separate',
  25. }
  26.  
  27. config.enableDebugLogging = false
  28.  
  29. const HOME = 'Home'
  30. const LATEST_TWEETS = 'Latest Tweets'
  31. const MESSAGES = 'Messages'
  32. const RETWEETS = 'Retweets'
  33.  
  34. let Selectors = {
  35. PRIMARY_COLUMN: 'div[data-testid="primaryColumn"]',
  36. PRIMARY_NAV: 'nav[aria-label="Primary"]',
  37. SIDEBAR_COLUMN: 'div[data-testid="sidebarColumn"]',
  38. TWEET: 'div[data-testid="tweet"]',
  39. }
  40.  
  41. Object.assign(Selectors, {
  42. SIDEBAR_FOOTER: `${Selectors.SIDEBAR_COLUMN} nav`,
  43. SIDEBAR_PEOPLE: `${Selectors.SIDEBAR_COLUMN} aside`,
  44. SIDEBAR_TRENDS: `${Selectors.SIDEBAR_COLUMN} section`,
  45. TIMELINE: `${Selectors.PRIMARY_COLUMN} section > h1 + div[aria-label] > div`,
  46. })
  47.  
  48. /** Title of the current page, without the ' / Twitter' suffix */
  49. let currentPage = ''
  50.  
  51. /** MutationObservers active on the current page */
  52. let pageObservers = []
  53. //#endregion
  54.  
  55. //#region Utility functions
  56. function addStyle(css) {
  57. let $style = document.createElement('style')
  58. $style.dataset.insertedBy = 'tweak-new-twitter'
  59. $style.textContent = css
  60. document.head.appendChild($style)
  61. return $style
  62. }
  63.  
  64. /**
  65. * @returns {Promise<HTMLElement>}
  66. */
  67. function getElement(selector, {
  68. name = null,
  69. stopIf = null,
  70. target = document,
  71. timeout = Infinity,
  72. } = {}) {
  73. return new Promise((resolve) => {
  74. let rafId
  75. let timeoutId
  76.  
  77. function stop($element, reason) {
  78. if ($element == null) {
  79. log(`stopped waiting for ${name || selector} after ${reason}`)
  80. }
  81. if (rafId) {
  82. cancelAnimationFrame(rafId)
  83. }
  84. if (timeoutId) {
  85. clearTimeout(timeoutId)
  86. }
  87. resolve($element)
  88. }
  89.  
  90. if (timeout !== Infinity) {
  91. timeoutId = setTimeout(stop, timeout, null, `${timeout}ms timeout`)
  92. }
  93.  
  94. function queryElement() {
  95. let $element = target.querySelector(selector)
  96. if ($element) {
  97. stop($element)
  98. }
  99. else if (stopIf != null && stopIf() === true) {
  100. stop(null, 'stopIf condition met')
  101. }
  102. else {
  103. rafId = requestAnimationFrame(queryElement)
  104. }
  105. }
  106.  
  107. queryElement()
  108. })
  109. }
  110.  
  111. function log(...args) {
  112. if (config.enableDebugLogging) {
  113. console.log(`TWT${currentPage ? `(${currentPage})` : ''}`, ...args)
  114. }
  115. }
  116.  
  117. function observeElement($element, listener, options = {childList: true}) {
  118. listener([])
  119. let observer = new MutationObserver(listener)
  120. observer.observe($element, options)
  121. return observer
  122. }
  123.  
  124. function pageIsNot(page) {
  125. return () => page != currentPage
  126. }
  127.  
  128. function s(n) {
  129. return n == 1 ? '' : 's'
  130. }
  131.  
  132. //#endregion
  133.  
  134. //#region Global observers
  135.  
  136. function observeHtmlFontSize() {
  137. let $html = document.querySelector('html')
  138. let $style = addStyle('')
  139. let lastFontSize = ''
  140.  
  141. log('observing html style attribute for font-size changes')
  142. let observer = observeElement($html, () => {
  143. if ($html.style.fontSize != lastFontSize) {
  144. lastFontSize = $html.style.fontSize
  145. log(`setting nav font size to ${lastFontSize}`)
  146. $style.textContent = [
  147. `${Selectors.PRIMARY_NAV} div[dir="auto"] span { font-size: ${lastFontSize}; font-weight: normal; }`,
  148. `${Selectors.PRIMARY_NAV} div[dir="auto"] { margin-top: -4px; }`
  149. ].join('\n')
  150. }
  151. }, {
  152. attributes: true,
  153. attributeFilter: ['style']
  154. })
  155.  
  156. return {
  157. disconnect() {
  158. $style.remove()
  159. observer.disconnect()
  160. }
  161. }
  162. }
  163.  
  164. let updateThemeColor = (function() {
  165. let $style = addStyle('')
  166. let lastThemeColor = null
  167.  
  168. return async function updateThemeColor() {
  169. // Only try to update if the "Customize your view" dialog os open or we
  170. // haven't set an inital color yet.
  171. if (location.pathname !== '/i/display' && lastThemeColor != null) {
  172. return
  173. }
  174.  
  175. let $tweetButton = await getElement('a[data-testid="SideNav_NewTweet_Button"]', {
  176. name: 'Tweet button'
  177. })
  178.  
  179. let themeColor = getComputedStyle($tweetButton).backgroundColor
  180. if (themeColor === lastThemeColor) {
  181. return
  182. }
  183. log(`setting theme color to ${themeColor}`)
  184. lastThemeColor = themeColor
  185. $style.textContent = [
  186. 'body.Home main h2:not(#twt_retweets)',
  187. 'body.LatestTweets main h2:not(#twt_retweets)',
  188. 'body.Retweets #twt_retweets',
  189. ].join(', ') + ` { color: ${lastThemeColor}; }`
  190. }
  191. })()
  192.  
  193. async function observeTitle() {
  194. let $title = await getElement('title', {name: '<title>'})
  195. log('observing <title>')
  196. return observeElement($title, () => onTitleChange($title.textContent), {
  197. childList: true,
  198. })
  199. }
  200. //#endregion
  201.  
  202. //#region Page observers
  203. async function observeSidebarAppearance(page) {
  204. let $primaryColumn = await getElement(Selectors.PRIMARY_COLUMN, {
  205. name: 'primary column',
  206. stopIf: pageIsNot(page),
  207. })
  208. log('observing responsive sidebar')
  209. pageObservers.push(
  210. observeElement($primaryColumn.parentNode, (mutations) => {
  211. mutations.forEach((mutation) => {
  212. mutation.addedNodes.forEach((el) => {
  213. if (el.dataset.testid == 'sidebarColumn') {
  214. log('sidebar appeared')
  215. hideSidebarContents(page)
  216. }
  217. })
  218. })
  219. })
  220. )
  221. }
  222.  
  223. async function observeTimeline(page) {
  224. let $timeline = await getElement(Selectors.TIMELINE, {
  225. name: 'timeline',
  226. stopIf: pageIsNot(page),
  227. })
  228. if ($timeline == null) {
  229. return
  230. }
  231.  
  232. // On 2020-04-03 Twitter switched to a new way of rendering the timeline which replaces an initial
  233. // container with the real element which holds timeline tweets and reduces the number of elements
  234. // wrapping the timeline.
  235. //
  236. // v1.9 was released to handle this.
  237. //
  238. // On 2020-04-05 they switched back to the old method.
  239. //
  240. // This attempts to support both approaches in case they keeping switching between the two.
  241.  
  242. // The "new" inital timeline element is a placeholder which doesn't have a style attribute
  243. // The "old" timeline has 2 wrapper divs which apply padding via the DOM .style object
  244. if ($timeline.hasAttribute('style')) {
  245. // The "old" timeline is nested one level deeper and the initial container has padding-bottom
  246. // <div aria-label="Timeline: Your Home Timeline">
  247. // <div style="padding-bottom: 0px"> <!-- current $timeline -->
  248. // <div style="padding-top: ...px; padding-bottom: ...px"> <!-- we want to observe this -->
  249. // <div> <!-- tweet elements are at this level -->
  250. // ...
  251. if ($timeline.style.paddingBottom) {
  252. $timeline = $timeline.firstElementChild;
  253. log('observing "old" timeline', {$timeline})
  254. }
  255. else {
  256. log('observing "new" timeline', {$timeline})
  257. }
  258. pageObservers.push(
  259. observeElement($timeline, () => onTimelineChange($timeline, page))
  260. )
  261. }
  262. else {
  263. log('waiting for real "new" timeline')
  264. pageObservers.push(
  265. observeElement($timeline.parentNode, (mutations) => {
  266. mutations.forEach((mutation) => {
  267. mutation.addedNodes.forEach(($timeline) => {
  268. log('observing "new" timeline', {$timeline})
  269. pageObservers.push(
  270. observeElement($timeline, () => onTimelineChange($timeline, page))
  271. )
  272. })
  273. })
  274. })
  275. )
  276. }
  277. }
  278. //#endregion
  279.  
  280. //#region Tweak functions
  281. async function addRetweetsHeader(page) {
  282. let $timelineTitle = await getElement('main h2', {
  283. name: 'timeline title',
  284. stopIf: pageIsNot(page),
  285. })
  286. if ($timelineTitle != null &&
  287. document.querySelector('#twt_retweets') == null) {
  288. log('inserting Retweets header')
  289. let div = document.createElement('div')
  290. div.innerHTML = $timelineTitle.parentElement.outerHTML
  291. let $retweets = div.firstElementChild
  292. $retweets.querySelector('h2').id = 'twt_retweets'
  293. $retweets.querySelector('span').textContent = RETWEETS
  294. // This script assumes navigation has occurred when the document title changes,
  295. // so by changing the title to "Retweets" we effectively fakenavigation to a
  296. // non-existent Retweets page.
  297. $retweets.addEventListener('click', () => {
  298. if (!document.title.startsWith(RETWEETS)) {
  299. document.title = `${RETWEETS} / Twitter`
  300. }
  301. window.scrollTo({top: 0})
  302. })
  303. $timelineTitle.parentElement.parentElement.insertAdjacentElement('afterend', $retweets)
  304. $timelineTitle.parentElement.addEventListener('click', () => {
  305. if (!document.title.startsWith(page)) {
  306. document.title = `${page} / Twitter`
  307. }
  308. })
  309. }
  310. }
  311.  
  312. function addStaticCss() {
  313. var cssRules = []
  314. var hideCssSelectors = []
  315. if (config.hideSidebarContent) {
  316. hideCssSelectors.push(
  317. Selectors.SIDEBAR_TRENDS,
  318. Selectors.SIDEBAR_PEOPLE,
  319. Selectors.SIDEBAR_FOOTER
  320. )
  321. }
  322. if (config.hideExploreNav) {
  323. hideCssSelectors.push(`${Selectors.PRIMARY_NAV} a[href="/explore"]`)
  324. }
  325. if (config.hideBookmarksNav) {
  326. hideCssSelectors.push(`${Selectors.PRIMARY_NAV} a[href="/i/bookmarks"]`)
  327. }
  328. if (config.hideListsNav) {
  329. hideCssSelectors.push(`${Selectors.PRIMARY_NAV} a[href*="/lists"]`)
  330. }
  331. if (hideCssSelectors.length > 0) {
  332. cssRules.push(`${hideCssSelectors.join(', ')} { display: none !important; }`)
  333. }
  334. if (cssRules.length > 0) {
  335. addStyle(cssRules.join('\n'))
  336. }
  337. }
  338.  
  339. async function hideSidebarContents(page) {
  340. let trends = getElement(Selectors.SIDEBAR_TRENDS, {
  341. name: 'sidebar trends',
  342. stopIf: pageIsNot(page),
  343. timeout: 4000,
  344. }).then(($trends) => {
  345. if ($trends == null) {
  346. return false
  347. }
  348. let $trendsModule = $trends.parentElement.parentElement.parentElement
  349. $trendsModule.style.display = 'none'
  350. // Hide surrounding elements which draw separators between modules
  351. if ($trendsModule.previousElementSibling &&
  352. $trendsModule.previousElementSibling.childElementCount == 0) {
  353. (/** @type {HTMLElement} */ $trendsModule.previousElementSibling).style.display = 'none'
  354. }
  355. if ($trendsModule.nextElementSibling &&
  356. $trendsModule.nextElementSibling.childElementCount == 0) {
  357. (/** @type {HTMLElement} */ $trendsModule.nextElementSibling).style.display = 'none'
  358. }
  359. return true
  360. })
  361.  
  362. let people = getElement(Selectors.SIDEBAR_PEOPLE, {
  363. name: 'sidebar people',
  364. stopIf: pageIsNot(page),
  365. timeout: 4000,
  366. }).then(($people) => {
  367. if ($people == null) {
  368. return false
  369. }
  370. let $peopleModule
  371. if ($people.getAttribute('aria-label') == 'Relevant people') {
  372. // "Relevant people" section when viewing a Tweet/thread
  373. $peopleModule = $people.parentElement
  374. }
  375. else {
  376. // "Who to follow" section
  377. $peopleModule = $people.parentElement
  378. }
  379. $peopleModule.style.display = 'none'
  380. return true
  381. })
  382.  
  383. let [hidTrends, hidPeople] = await Promise.all([trends, people])
  384. log(hidTrends == true && hidPeople == true
  385. ? 'hid all sidebar content'
  386. : 'stopped waiting for sidebar content')
  387. }
  388.  
  389. function onTitleChange(title) {
  390. // Ignore Flash of Uninitialised Title when navigating to a screen for the
  391. // first time.
  392. if (title == 'Twitter') {
  393. log('ignoring Flash of Uninitialised Title')
  394. return
  395. }
  396.  
  397. // Only allow the same page to re-process if the "Customize your view" dialog
  398. // is currently open.
  399. let newPage = title.split(' / ')[0]
  400. if (newPage == currentPage && location.pathname != '/i/display') {
  401. log('ignoring duplicate title change')
  402. return
  403. }
  404.  
  405. // Assumption: all non-FOUT, non-duplicate title changes are navigation, which
  406. // needs the screen to be re-processed.
  407.  
  408. if (pageObservers.length > 0) {
  409. log(`disconnecting ${pageObservers.length} page observer${s(pageObservers.length)}`)
  410. pageObservers.forEach(observer => observer.disconnect())
  411. pageObservers = []
  412. }
  413.  
  414. currentPage = newPage
  415.  
  416. log('processing new page')
  417.  
  418. if (config.alwaysUseLatestTweets && currentPage == HOME) {
  419. return switchToLatestTweets(currentPage)
  420. }
  421.  
  422. if (config.retweets == 'separate') {
  423. document.body.classList.toggle(HOME, currentPage == HOME)
  424. document.body.classList.toggle('LatestTweets', currentPage == LATEST_TWEETS)
  425. document.body.classList.toggle(RETWEETS, currentPage == RETWEETS)
  426. updateThemeColor()
  427. }
  428.  
  429. if (config.retweets == 'separate' && (currentPage == LATEST_TWEETS || currentPage == HOME)) {
  430. addRetweetsHeader(currentPage)
  431. }
  432.  
  433. if (config.retweets != 'ignore' && (currentPage == LATEST_TWEETS || currentPage == RETWEETS || currentPage == HOME)) {
  434. observeTimeline(currentPage)
  435. }
  436.  
  437. if (config.hideSidebarContent && currentPage != MESSAGES) {
  438. hideSidebarContents(currentPage)
  439. observeSidebarAppearance(currentPage)
  440. }
  441. }
  442.  
  443. function getTweetType($tweet) {
  444. if ($tweet.lastElementChild.children.length > 2 &&
  445. $tweet.lastElementChild.children[2].textContent.includes('Promoted')) {
  446. return 'PROMOTED'
  447. }
  448. if ($tweet.previousElementSibling != null &&
  449. $tweet.previousElementSibling.textContent.includes('Retweeted')) {
  450. return 'RETWEET'
  451. }
  452. return 'TWEET'
  453. }
  454.  
  455. function shouldHideTimelineItem(itemType, page) {
  456. return itemType == 'PROMOTED' || page == RETWEETS ? itemType != 'RETWEET' : itemType != 'TWEET'
  457. }
  458.  
  459. function onTimelineChange($timeline, page) {
  460. log(`processing ${$timeline.children.length} timeline item${s($timeline.children.length)}`)
  461. let previousItemType = null
  462. for (let $item of $timeline.children) {
  463. let hideItem = null
  464. let $tweet = $item.querySelector(Selectors.TWEET)
  465. if ($tweet != null) {
  466. previousItemType = getTweetType($tweet)
  467. hideItem = shouldHideTimelineItem(previousItemType, page)
  468. }
  469. else {
  470. // Assume a non-tweet node following a tweet node is related to the previous tweet
  471. // "Show this thread" links sometimes appear in the subsequent timeline node as an <a>
  472. if (previousItemType != null) {
  473. hideItem = shouldHideTimelineItem(previousItemType, page)
  474. }
  475. // The first item in the timeline is sometimes an empty placeholder <div>
  476. else if ($item !== $timeline.firstElementChild) {
  477. log('unhandled timeline item', $item)
  478. }
  479. previousItemType = null
  480. }
  481.  
  482. if (hideItem != null) {
  483. (/** @type {HTMLElement} */ $item.firstElementChild).style.display = hideItem ? 'none' : ''
  484. }
  485. }
  486. }
  487.  
  488. async function switchToLatestTweets(page) {
  489. let $switchButton = await getElement('div[aria-label="Top Tweets on"]', {
  490. name: 'timeline switch button',
  491. stopIf: pageIsNot(page),
  492. })
  493.  
  494. if ($switchButton == null) {
  495. return false
  496. }
  497.  
  498. $switchButton.click()
  499.  
  500. let $seeLatestTweetsInstead = await getElement('div[role="menu"] div[role="menuitem"]', {
  501. name: '"See latest Tweets instead" menu item',
  502. stopIf: pageIsNot(page),
  503. })
  504.  
  505. if ($seeLatestTweetsInstead == null) {
  506. return false
  507. }
  508.  
  509. $seeLatestTweetsInstead.closest('div[tabindex="0"]').click()
  510. return true
  511. }
  512.  
  513. //#region Main
  514. async function main() {
  515. log('config', config)
  516.  
  517. let htmlFontSizeObserver
  518. let titleObserver
  519.  
  520. addStaticCss()
  521.  
  522. if (config.navBaseFontSize) {
  523. htmlFontSizeObserver = observeHtmlFontSize()
  524. }
  525.  
  526. if (config.retweets != 'ignore' || config.hideSidebarContent) {
  527. titleObserver = await observeTitle()
  528. }
  529. }
  530.  
  531. main()
  532. //#endregion