Tweak New Twitter

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

当前为 2020-04-03 提交的版本,查看 最新版本

  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 10
  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. // The inital timeline element is a placeholder which doesn't have a style attribute
  232. if ($timeline.hasAttribute('style')) {
  233. log('observing timeline', {$timeline})
  234. pageObservers.push(
  235. observeElement($timeline, () => onTimelineChange($timeline, page))
  236. )
  237. }
  238. else {
  239. log('waiting for real timeline')
  240. pageObservers.push(
  241. observeElement($timeline.parentNode, (mutations) => {
  242. mutations.forEach((mutation) => {
  243. mutation.addedNodes.forEach(($timeline) => {
  244. log('observing timeline', {$timeline})
  245. pageObservers.push(
  246. observeElement($timeline, () => onTimelineChange($timeline, page))
  247. )
  248. })
  249. })
  250. })
  251. )
  252. }
  253. }
  254. //#endregion
  255.  
  256. //#region Tweak functions
  257. async function addRetweetsHeader(page) {
  258. let $timelineTitle = await getElement('main h2', {
  259. name: 'timeline title',
  260. stopIf: pageIsNot(page),
  261. })
  262. if ($timelineTitle != null &&
  263. document.querySelector('#twt_retweets') == null) {
  264. log('inserting Retweets header')
  265. let div = document.createElement('div')
  266. div.innerHTML = $timelineTitle.parentElement.outerHTML
  267. let $retweets = div.firstElementChild
  268. $retweets.querySelector('h2').id = 'twt_retweets'
  269. $retweets.querySelector('span').textContent = RETWEETS
  270. // This script assumes navigation has occurred when the document title changes,
  271. // so by changing the title to "Retweets" we effectively fakenavigation to a
  272. // non-existent Retweets page.
  273. $retweets.addEventListener('click', () => {
  274. if (!document.title.startsWith(RETWEETS)) {
  275. document.title = `${RETWEETS} / Twitter`
  276. }
  277. window.scrollTo({top: 0})
  278. })
  279. $timelineTitle.parentElement.parentElement.insertAdjacentElement('afterend', $retweets)
  280. $timelineTitle.parentElement.addEventListener('click', () => {
  281. if (!document.title.startsWith(page)) {
  282. document.title = `${page} / Twitter`
  283. }
  284. })
  285. }
  286. }
  287.  
  288. function addStaticCss() {
  289. var cssRules = []
  290. var hideCssSelectors = []
  291. if (config.hideSidebarContent) {
  292. hideCssSelectors.push(
  293. Selectors.SIDEBAR_TRENDS,
  294. Selectors.SIDEBAR_PEOPLE,
  295. Selectors.SIDEBAR_FOOTER
  296. )
  297. }
  298. if (config.hideExploreNav) {
  299. hideCssSelectors.push(`${Selectors.PRIMARY_NAV} a[href="/explore"]`)
  300. }
  301. if (config.hideBookmarksNav) {
  302. hideCssSelectors.push(`${Selectors.PRIMARY_NAV} a[href="/i/bookmarks"]`)
  303. }
  304. if (config.hideListsNav) {
  305. hideCssSelectors.push(`${Selectors.PRIMARY_NAV} a[href*="/lists"]`)
  306. }
  307. if (hideCssSelectors.length > 0) {
  308. cssRules.push(`${hideCssSelectors.join(', ')} { display: none !important; }`)
  309. }
  310. if (cssRules.length > 0) {
  311. addStyle(cssRules.join('\n'))
  312. }
  313. }
  314.  
  315. async function hideSidebarContents(page) {
  316. let trends = getElement(Selectors.SIDEBAR_TRENDS, {
  317. name: 'sidebar trends',
  318. stopIf: pageIsNot(page),
  319. timeout: 4000,
  320. }).then(($trends) => {
  321. if ($trends == null) {
  322. return false
  323. }
  324. let $trendsModule = $trends.parentElement.parentElement.parentElement
  325. $trendsModule.style.display = 'none'
  326. // Hide surrounding elements which draw separators between modules
  327. if ($trendsModule.previousElementSibling &&
  328. $trendsModule.previousElementSibling.childElementCount == 0) {
  329. (/** @type {HTMLElement} */ $trendsModule.previousElementSibling).style.display = 'none'
  330. }
  331. if ($trendsModule.nextElementSibling &&
  332. $trendsModule.nextElementSibling.childElementCount == 0) {
  333. (/** @type {HTMLElement} */ $trendsModule.nextElementSibling).style.display = 'none'
  334. }
  335. return true
  336. })
  337.  
  338. let people = getElement(Selectors.SIDEBAR_PEOPLE, {
  339. name: 'sidebar people',
  340. stopIf: pageIsNot(page),
  341. timeout: 4000,
  342. }).then(($people) => {
  343. if ($people == null) {
  344. return false
  345. }
  346. let $peopleModule
  347. if ($people.getAttribute('aria-label') == 'Relevant people') {
  348. // "Relevant people" section when viewing a Tweet/thread
  349. $peopleModule = $people.parentElement
  350. }
  351. else {
  352. // "Who to follow" section
  353. $peopleModule = $people.parentElement
  354. }
  355. $peopleModule.style.display = 'none'
  356. return true
  357. })
  358.  
  359. let [hidTrends, hidPeople] = await Promise.all([trends, people])
  360. log(hidTrends == true && hidPeople == true
  361. ? 'hid all sidebar content'
  362. : 'stopped waiting for sidebar content')
  363. }
  364.  
  365. function onTitleChange(title) {
  366. // Ignore Flash of Uninitialised Title when navigating to a screen for the
  367. // first time.
  368. if (title == 'Twitter') {
  369. log('ignoring Flash of Uninitialised Title')
  370. return
  371. }
  372.  
  373. // Only allow the same page to re-process if the "Customize your view" dialog
  374. // is currently open.
  375. let newPage = title.split(' / ')[0]
  376. if (newPage == currentPage && location.pathname != '/i/display') {
  377. log('ignoring duplicate title change')
  378. return
  379. }
  380.  
  381. // Assumption: all non-FOUT, non-duplicate title changes are navigation, which
  382. // needs the screen to be re-processed.
  383.  
  384. if (pageObservers.length > 0) {
  385. log(`disconnecting ${pageObservers.length} page observer${s(pageObservers.length)}`)
  386. pageObservers.forEach(observer => observer.disconnect())
  387. pageObservers = []
  388. }
  389.  
  390. currentPage = newPage
  391.  
  392. log('processing new page')
  393.  
  394. if (config.alwaysUseLatestTweets && currentPage == HOME) {
  395. return switchToLatestTweets(currentPage)
  396. }
  397.  
  398. if (config.retweets == 'separate') {
  399. document.body.classList.toggle(HOME, currentPage == HOME)
  400. document.body.classList.toggle('LatestTweets', currentPage == LATEST_TWEETS)
  401. document.body.classList.toggle(RETWEETS, currentPage == RETWEETS)
  402. updateThemeColor()
  403. }
  404.  
  405. if (config.retweets == 'separate' && (currentPage == LATEST_TWEETS || currentPage == HOME)) {
  406. addRetweetsHeader(currentPage)
  407. }
  408.  
  409. if (config.retweets != 'ignore' && (currentPage == LATEST_TWEETS || currentPage == RETWEETS || currentPage == HOME)) {
  410. observeTimeline(currentPage)
  411. }
  412.  
  413. if (config.hideSidebarContent && currentPage != MESSAGES) {
  414. hideSidebarContents(currentPage)
  415. observeSidebarAppearance(currentPage)
  416. }
  417. }
  418.  
  419. function getTweetType($tweet) {
  420. if ($tweet.lastElementChild.children.length > 2 &&
  421. $tweet.lastElementChild.children[2].textContent.includes('Promoted')) {
  422. return 'PROMOTED'
  423. }
  424. if ($tweet.previousElementSibling != null &&
  425. $tweet.previousElementSibling.textContent.includes('Retweeted')) {
  426. return 'RETWEET'
  427. }
  428. return 'TWEET'
  429. }
  430.  
  431. function shouldHideTimelineItem(itemType, page) {
  432. return itemType == 'PROMOTED' || page == RETWEETS ? itemType != 'RETWEET' : itemType != 'TWEET'
  433. }
  434.  
  435. function onTimelineChange($timeline, page) {
  436. log(`processing ${$timeline.children.length} timeline item${s($timeline.children.length)}`)
  437. let previousItemType = null
  438. for (let $item of $timeline.children) {
  439. let hideItem = null
  440. let $tweet = $item.querySelector(Selectors.TWEET)
  441. if ($tweet != null) {
  442. previousItemType = getTweetType($tweet)
  443. hideItem = shouldHideTimelineItem(previousItemType, page)
  444. }
  445. else {
  446. // Assume a non-tweet node following a tweet node is related to the previous tweet
  447. // "Show this thread" links sometimes appear in the subsequent timeline node as an <a>
  448. if (previousItemType != null) {
  449. hideItem = shouldHideTimelineItem(previousItemType, page)
  450. }
  451. // The first item in the timeline is sometimes an empty placeholder <div>
  452. else if ($item !== $timeline.firstElementChild) {
  453. log('unhandled timeline item', $item)
  454. }
  455. previousItemType = null
  456. }
  457.  
  458. if (hideItem != null) {
  459. (/** @type {HTMLElement} */ $item.firstElementChild).style.display = hideItem ? 'none' : ''
  460. }
  461. }
  462. }
  463.  
  464. async function switchToLatestTweets(page) {
  465. let $switchButton = await getElement('div[aria-label="Top Tweets on"]', {
  466. name: 'timeline switch button',
  467. stopIf: pageIsNot(page),
  468. })
  469.  
  470. if ($switchButton == null) {
  471. return false
  472. }
  473.  
  474. $switchButton.click()
  475.  
  476. let $seeLatestTweetsInstead = await getElement('div[role="menu"] div[role="menuitem"]', {
  477. name: '"See latest Tweets instead" menu item',
  478. stopIf: pageIsNot(page),
  479. })
  480.  
  481. if ($seeLatestTweetsInstead == null) {
  482. return false
  483. }
  484.  
  485. $seeLatestTweetsInstead.closest('div[tabindex="0"]').click()
  486. return true
  487. }
  488.  
  489. //#region Main
  490. async function main() {
  491. log('config', config)
  492.  
  493. let htmlFontSizeObserver
  494. let titleObserver
  495.  
  496. addStaticCss()
  497.  
  498. if (config.navBaseFontSize) {
  499. htmlFontSizeObserver = observeHtmlFontSize()
  500. }
  501.  
  502. if (config.retweets != 'ignore' || config.hideSidebarContent) {
  503. titleObserver = await observeTitle()
  504. }
  505. }
  506.  
  507. main()
  508. //#endregion