Tweak New Twitter

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

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

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