Tweak New Twitter

Reduce algorithmic content on Twitter, hide toxic trends, control which shared tweets appear on your timeline, and improve the UI

当前为 2021-07-17 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Tweak New Twitter
  3. // @description Reduce algorithmic content on Twitter, hide toxic trends, control which shared tweets appear on your timeline, and improve the UI
  4. // @namespace https://github.com/insin/tweak-new-twitter/
  5. // @match https://twitter.com/*
  6. // @match https://mobile.twitter.com/*
  7. // @version 28
  8. // ==/UserScript==
  9.  
  10. //#region Custom types
  11. /** @typedef {'TWEET'|'QUOTE_TWEET'|'RETWEET'|'PROMOTED_TWEET'|'HEADING'} TimelineItemType */
  12.  
  13. /** @typedef {'separate'|'hide'|'ignore'} SharedTweetsConfig */
  14.  
  15. /** @typedef {'highlight'|'hide'|'ignore'} VerifiedAccountsConfig */
  16. //#endregion
  17.  
  18. //#region Config & variables
  19. /**
  20. * Default config enables all features.
  21. *
  22. * You'll need to edit the config object manually for now if you're using this
  23. * as a user script.
  24. */
  25. const config = {
  26. alwaysUseLatestTweets: true,
  27. fastBlock: true,
  28. hideAccountSwitcher: true,
  29. hideBookmarksNav: true,
  30. hideExploreNav: true,
  31. hideListsNav: true,
  32. hideMessagesDrawer: true,
  33. hideMoreTweets: true,
  34. hideSidebarContent: true,
  35. hideWhoToFollowEtc: true,
  36. navBaseFontSize: true,
  37. pinQuotedTweetOnQuoteTweetsPage: true,
  38. /** @type {SharedTweetsConfig} */
  39. quoteTweets: 'ignore',
  40. /** @type {SharedTweetsConfig} */
  41. retweets: 'separate',
  42. /** @type {VerifiedAccountsConfig} */
  43. verifiedAccounts: 'ignore',
  44. }
  45.  
  46. // Only for use when developing, not exposed in the options UI
  47. config.enableDebugLogging = false
  48.  
  49. // Document title names used by Twitter
  50. const HOME = 'Home'
  51. const LATEST_TWEETS = 'Latest Tweets'
  52. const MESSAGES = 'Messages'
  53. const QUOTE_TWEETS = 'Quote Tweets'
  54.  
  55. const PROFILE_TITLE_RE = /\(@[a-z\d_]{1,15}\)$/i
  56. const TITLE_NOTIFICATION_RE = /^\(\d+\+?\) /
  57. const URL_TWEET_ID_RE = /\/status\/(\d+)$/
  58. const URL_PHOTO_RE = /photo\/\d$/
  59.  
  60. const Selectors = {
  61. MESSAGES_DRAWER: 'div[data-testid="DMDrawer"]',
  62. NAV_HOME_LINK: 'a[data-testid="AppTabBar_Home_Link"]',
  63. PRIMARY_COLUMN: 'div[data-testid="primaryColumn"]',
  64. PRIMARY_NAV: 'nav[aria-label="Primary"]',
  65. PROMOTED_TWEET: '[data-testid="placementTracking"]',
  66. SIDEBAR_COLUMN: 'div[data-testid="sidebarColumn"]',
  67. TIMELINE_HEADING: 'h2[role="heading"]',
  68. TWEET: 'div[data-testid="tweet"]',
  69. VERIFIED_TICK: 'svg[aria-label="Verified account"]',
  70. }
  71.  
  72. Object.assign(Selectors, {
  73. TIMELINE: `${Selectors.PRIMARY_COLUMN} section > h1 + div[aria-label] > div`,
  74. })
  75.  
  76. /** Title of the current page, without the ' / Twitter' suffix */
  77. let currentPage = ''
  78.  
  79. /** Notification count in the title (including trailing space), e.g. '(1) ' */
  80. let currentNotificationCount = ''
  81.  
  82. /** Current URL path */
  83. let currentPath = ''
  84.  
  85. /** Flag for a Home / Latest Tweets link having been clicked */
  86. let homeLinkClicked = false
  87.  
  88. /** The last title we saw for the home timeline */
  89. let lastHomeTimelineTitle = ''
  90.  
  91. /**
  92. * MutationObservers active on the current page
  93. * @type MutationObserver[]
  94. */
  95. let pageObservers = []
  96.  
  97. // Config for the fake timeline used to display retweets/quote tweets separately
  98. let separatedTweetsTimelineTitle = ''
  99. let separatedTweetsTimelineName = ''
  100.  
  101. function configureSeparatedTweetsTimeline() {
  102. if (config.retweets == 'separate' && config.quoteTweets == 'separate') {
  103. separatedTweetsTimelineTitle = separatedTweetsTimelineName = 'Shared Tweets'
  104. } else if (config.retweets == 'separate') {
  105. separatedTweetsTimelineTitle = separatedTweetsTimelineName = 'Retweets'
  106. } else if (config.quoteTweets == 'separate') {
  107. // Twitter already uses 'Quote Tweets' as a page title
  108. // ☠ ¡This string starts with a ZWSP! ☠
  109. separatedTweetsTimelineTitle = separatedTweetsTimelineName = '​Quote Tweets'
  110. }
  111. }
  112.  
  113. function isOnHomeTimeline() {
  114. return currentPage == LATEST_TWEETS || currentPage == separatedTweetsTimelineTitle || currentPage == HOME
  115. }
  116. //#endregion
  117.  
  118. //#region Utility functions
  119. /**
  120. * @param {string} css
  121. * @return {HTMLStyleElement}
  122. */
  123. function addStyle(css) {
  124. let $style = document.createElement('style')
  125. $style.dataset.insertedBy = 'tweak-new-twitter'
  126. $style.textContent = css
  127. document.head.appendChild($style)
  128. return $style
  129. }
  130.  
  131. /**
  132. * @param {string} selector
  133. * @param {{
  134. * name?: string
  135. * stopIf?: () => boolean
  136. * context?: Document | HTMLElement
  137. * timeout?: number
  138. * }} options
  139. * @returns {Promise<HTMLElement>}
  140. */
  141. function getElement(selector, {
  142. name = null,
  143. stopIf = null,
  144. context = document,
  145. timeout = Infinity,
  146. } = {}) {
  147. return new Promise((resolve) => {
  148. let startTime = Date.now()
  149. let rafId
  150. let timeoutId
  151.  
  152. function stop($element, reason) {
  153. if ($element == null) {
  154. log(`stopped waiting for ${name || selector} after ${reason}`)
  155. }
  156. else if (Date.now() > startTime) {
  157. log(`${name || selector} appeared after ${Date.now() - startTime}ms`)
  158. }
  159. if (rafId) {
  160. cancelAnimationFrame(rafId)
  161. }
  162. if (timeoutId) {
  163. clearTimeout(timeoutId)
  164. }
  165. resolve($element)
  166. }
  167.  
  168. if (timeout !== Infinity) {
  169. timeoutId = setTimeout(stop, timeout, null, `${timeout}ms timeout`)
  170. }
  171.  
  172. function queryElement() {
  173. let $element = context.querySelector(selector)
  174. if ($element) {
  175. stop($element)
  176. }
  177. else if (stopIf?.() === true) {
  178. stop(null, 'stopIf condition met')
  179. }
  180. else {
  181. rafId = requestAnimationFrame(queryElement)
  182. }
  183. }
  184.  
  185. queryElement()
  186. })
  187. }
  188.  
  189. function log(...args) {
  190. if (config.enableDebugLogging) {
  191. console.log(`🧨${currentPage ? `(${currentPage})` : ''}`, ...args)
  192. }
  193. }
  194.  
  195. /**
  196. * Convenience wrapper for the MutationObserver API.
  197. *
  198. * The callback is called immediately to support using an observer and its
  199. * options as a trigger for any change, without looking at MutationRecords.
  200. *
  201. * @param {Node} $element
  202. * @param {MutationCallback} callback
  203. * @param {MutationObserverInit} options
  204. */
  205. function observeElement($element, callback, options = {childList: true}) {
  206. let observer = new MutationObserver(callback)
  207. callback([], observer)
  208. observer.observe($element, options)
  209. return observer
  210. }
  211.  
  212. /**
  213. * @param {string} page
  214. * @returns {() => boolean}
  215. */
  216. function pageIsNot(page) {
  217. return () => page != currentPage
  218. }
  219.  
  220. /**
  221. * @param {string} path
  222. * @returns {() => boolean}
  223. */
  224. function pathIsNot(path) {
  225. return () => path != currentPath
  226. }
  227.  
  228. /**
  229. * @param {number} n
  230. * @returns {string}
  231. */
  232. function s(n) {
  233. return n == 1 ? '' : 's'
  234. }
  235. //#endregion
  236.  
  237. //#region Global observers
  238. /**
  239. * When the background setting is changed in "Customize your view", <body>'s
  240. * backgroundColor is changed and the app is re-rendered, so we need to
  241. * re-process the current page.
  242. */
  243. function observeBodyBackgroundColor() {
  244. let $body = document.querySelector('body')
  245. let lastBackgroundColor = $body.style.backgroundColor
  246.  
  247. log('observing body style attribute for backgroundColor changes')
  248. observeElement($body, () => {
  249. if ($body.style.backgroundColor != lastBackgroundColor) {
  250. lastBackgroundColor = $body.style.backgroundColor
  251. log(`body backgroundColor changed - re-processing current page`)
  252. processCurrentPage()
  253. }
  254. }, {
  255. attributes: true,
  256. attributeFilter: ['style']
  257. })
  258. }
  259.  
  260. /**
  261. * When the font size setting is changed in "Customize your view", <html>'s
  262. * fontSize is changed, after which we need to update nav font size accordingly.
  263. */
  264. function observeHtmlFontSize() {
  265. let $html = document.querySelector('html')
  266. let $style = addStyle('')
  267. let lastFontSize = ''
  268.  
  269. log('observing html style attribute for fontSize changes')
  270. observeElement($html, () => {
  271. if ($html.style.fontSize != lastFontSize) {
  272. lastFontSize = $html.style.fontSize
  273. log(`setting nav font size to ${lastFontSize}`)
  274. $style.textContent = [
  275. `${Selectors.PRIMARY_NAV} div[dir="auto"] span { font-size: ${lastFontSize}; font-weight: normal; }`,
  276. `${Selectors.PRIMARY_NAV} div[dir="auto"] { margin-top: -4px; }`
  277. ].join('\n')
  278. }
  279. }, {
  280. attributes: true,
  281. attributeFilter: ['style']
  282. })
  283. }
  284.  
  285. async function observePopups() {
  286. let $keyboardWrapper = await getElement('[data-at-shortcutkeys]', {
  287. name: 'keyboard wrapper',
  288. })
  289. log('observing popups')
  290. observeElement($keyboardWrapper.previousElementSibling, (mutations) => {
  291. mutations.forEach((mutation) => {
  292. // The first popup takes another tick to render content
  293. mutation.addedNodes.forEach($el => requestAnimationFrame(() => onPopup($el)))
  294. })
  295. })
  296. }
  297.  
  298. async function observeThemeChangeRerenders() {
  299. let $themeChangeBoundary = await getElement('#react-root > div > div')
  300. log('observing theme change re-renders')
  301. observeElement($themeChangeBoundary, () => updateThemeColor())
  302. }
  303.  
  304. async function observeTitle() {
  305. let $title = await getElement('title', {name: '<title>'})
  306. log('observing <title>')
  307. observeElement($title, () => onTitleChange($title.textContent))
  308. }
  309. //#endregion
  310.  
  311. //#region Page observers for the current page
  312. async function observeSidebar(page) {
  313. let $primaryColumn = await getElement(Selectors.PRIMARY_COLUMN, {
  314. name: 'primary column',
  315. stopIf: pageIsNot(page),
  316. })
  317.  
  318. /**
  319. * Hides <aside> or <section> elements as they appear in the sidebar.
  320. * @param {MutationRecord[]} mutations
  321. */
  322. function sidebarMutationCallback(mutations) {
  323. mutations.forEach((mutation) => {
  324. mutation.addedNodes.forEach(($el) => {
  325. if ($el.nodeType == Node.ELEMENT_NODE &&
  326. ($el.nodeName == 'ASIDE' || $el.nodeName == 'SECTION')) {
  327. hideSidebarElement(/** @type {HTMLElement} */ ($el))
  328. }
  329. })
  330. })
  331. }
  332.  
  333. let $sidebarColumn = document.querySelector(Selectors.SIDEBAR_COLUMN)
  334. if ($sidebarColumn) {
  335. log('observing sidebar')
  336. hideSidebarContents()
  337. pageObservers.push(
  338. observeElement($sidebarColumn, sidebarMutationCallback, {childList: true, subtree: true})
  339. )
  340. }
  341. else {
  342. log('waiting for sidebar to appear')
  343. }
  344.  
  345. pageObservers.push(
  346. observeElement($primaryColumn.parentNode, (mutations) => {
  347. mutations.forEach((mutation) => {
  348. mutation.addedNodes.forEach(($el) => {
  349. if (/** @type {HTMLElement} */ ($el).dataset.testid == 'sidebarColumn') {
  350. log('sidebar appeared')
  351. hideSidebarContents()
  352. observeElement($el, sidebarMutationCallback, {childList: true, subtree: true})
  353. }
  354. })
  355. })
  356. })
  357. )
  358. }
  359.  
  360. async function observeTimeline(page) {
  361. let $timeline = await getElement(Selectors.TIMELINE, {
  362. name: 'timeline',
  363. stopIf: pageIsNot(page),
  364. })
  365. if ($timeline == null) {
  366. return
  367. }
  368.  
  369. // On 2020-04-03 Twitter switched to a new way of rendering the timeline which replaces an initial
  370. // container with the real element which holds timeline tweets and reduces the number of elements
  371. // wrapping the timeline.
  372. //
  373. // v1.9 was released to handle this.
  374. //
  375. // On 2020-04-05 they switched back to the old method.
  376. //
  377. // This attempts to support both approaches in case they keeping switching between the two.
  378.  
  379. // The "new" inital timeline element is a placeholder which doesn't have a style attribute
  380. // The "old" timeline has 2 wrapper divs which apply padding via the DOM .style object
  381. if ($timeline.hasAttribute('style')) {
  382. // The "old" timeline is nested one level deeper and the initial container has padding-bottom
  383. // <div aria-label="Timeline: Your Home Timeline">
  384. // <div style="padding-bottom: 0px"> <!-- current $timeline -->
  385. // <div style="padding-top: ...px; padding-bottom: ...px"> <!-- we want to observe this -->
  386. // <div> <!-- tweet elements are at this level -->
  387. // ...
  388. if ($timeline.style.paddingBottom) {
  389. $timeline = /** @type {HTMLElement} */ ($timeline.firstElementChild)
  390. log('observing "old" timeline', {$timeline})
  391. }
  392. else {
  393. log('observing "new" timeline', {$timeline})
  394. }
  395. pageObservers.push(
  396. observeElement($timeline, () => onTimelineChange($timeline, page))
  397. )
  398. }
  399. else {
  400. log('waiting for real "new" timeline')
  401. let startTime = Date.now()
  402. pageObservers.push(
  403. observeElement($timeline.parentNode, (mutations) => {
  404. mutations.forEach((mutation) => {
  405. mutation.addedNodes.forEach(($timeline) => {
  406. if (Date.now() > startTime) {
  407. log(`"new" timeline appeared after ${Date.now() - startTime}ms`)
  408. }
  409. log('observing "new" timeline', {$timeline})
  410. pageObservers.push(
  411. observeElement($timeline, () => onTimelineChange($timeline, page))
  412. )
  413. })
  414. })
  415. })
  416. )
  417. }
  418. }
  419. //#endregion
  420.  
  421. //#region Tweak functions
  422. async function addSeparatedTweetsTimelineHeader(page) {
  423. let $timelineTitle = await getElement('main h2', {
  424. name: 'timeline title',
  425. stopIf: pageIsNot(page),
  426. })
  427. if ($timelineTitle != null &&
  428. document.querySelector('#tnt_separated_tweets') == null) {
  429. log('inserting separated tweets timeline header')
  430. let div = document.createElement('div')
  431. div.innerHTML = $timelineTitle.parentElement.outerHTML
  432. let $retweets = div.firstElementChild
  433. $retweets.querySelector('h2').id = 'tnt_separated_tweets'
  434. $retweets.querySelector('span').textContent = separatedTweetsTimelineName
  435. // This script assumes navigation has occurred when the document title changes, so by changing
  436. // the title we effectively fake navigation to a non-existent page representing the sparated
  437. // tweets timeline.
  438. $retweets.addEventListener('click', () => {
  439. if (!document.title.startsWith(separatedTweetsTimelineTitle)) {
  440. setTitle(separatedTweetsTimelineTitle)
  441. }
  442. window.scrollTo({top: 0})
  443. })
  444. $timelineTitle.parentElement.parentElement.insertAdjacentElement('afterend', $retweets)
  445. // Go back to the main timeline when the Latest Tweets / Home heading is clicked
  446. $timelineTitle.parentElement.addEventListener('click', () => {
  447. if (!document.title.startsWith(lastHomeTimelineTitle)) {
  448. homeLinkClicked = true
  449. setTitle(lastHomeTimelineTitle)
  450. }
  451. })
  452. // Go back to the main timeline when the Home nav link is clicked
  453. document.querySelector(Selectors.NAV_HOME_LINK).addEventListener('click', () => {
  454. homeLinkClicked = true
  455. if (location.pathname == '/home' && !document.title.startsWith(lastHomeTimelineTitle)) {
  456. setTitle(lastHomeTimelineTitle)
  457. }
  458. })
  459. }
  460. }
  461.  
  462. function addStaticCss() {
  463. var cssRules = []
  464. var hideCssSelectors = []
  465. if (config.hideSidebarContent) {
  466. hideCssSelectors.push(
  467. // Sidefooter
  468. `${Selectors.SIDEBAR_COLUMN} nav`,
  469. // Who to Follow
  470. `${Selectors.SIDEBAR_COLUMN} aside`,
  471. // What's Happening, Topics to Follow etc.
  472. `${Selectors.SIDEBAR_COLUMN} section`
  473. )
  474. }
  475. if (config.hideExploreNav) {
  476. hideCssSelectors.push(`${Selectors.PRIMARY_NAV} a[href="/explore"]`)
  477. }
  478. if (config.hideBookmarksNav) {
  479. hideCssSelectors.push(`${Selectors.PRIMARY_NAV} a[href="/i/bookmarks"]`)
  480. }
  481. if (config.hideListsNav) {
  482. hideCssSelectors.push(`${Selectors.PRIMARY_NAV} a[href*="/lists"]`)
  483. }
  484. if (config.hideAccountSwitcher) {
  485. cssRules.push(`
  486. header[role="banner"] > div > div > div > div:last-child {
  487. flex-shrink: 1 !important;
  488. align-items: flex-end !important;
  489. }
  490. [data-testid="SideNav_AccountSwitcher_Button"] > div:first-child,
  491. [data-testid="SideNav_AccountSwitcher_Button"] > div:first-child + div {
  492. display: none !important;
  493. }
  494. `)
  495. }
  496. if (config.hideMessagesDrawer) {
  497. hideCssSelectors.push(Selectors.MESSAGES_DRAWER)
  498. }
  499. if (hideCssSelectors.length > 0) {
  500. cssRules.push(`${hideCssSelectors.join(', ')} { display: none !important; }`)
  501. }
  502. if (cssRules.length > 0) {
  503. addStyle(cssRules.join('\n'))
  504. }
  505. }
  506.  
  507. /**
  508. * Attempts to determine the type of a timeline Tweet given the element with data-testid="tweet" on
  509. * it, falling back to TWEET if it doesn't appear to be one of the particular types we care about.
  510. * @param {HTMLElement} $tweet
  511. * @returns {TimelineItemType}
  512. */
  513. function getTweetType($tweet) {
  514. if ($tweet.closest(Selectors.PROMOTED_TWEET)) {
  515. return 'PROMOTED_TWEET'
  516. }
  517. if ($tweet.previousElementSibling?.textContent.includes('Retweeted')) {
  518. return 'RETWEET'
  519. }
  520. if ($tweet.querySelector('div[id^="id__"] > div[dir="auto"] > span')?.textContent.includes('Quote Tweet') ||
  521. // QTs of accounts you blocked are displayed as a nested <article> with "This Tweet is unavailable."
  522. $tweet.querySelector('article')) {
  523. return 'QUOTE_TWEET'
  524. }
  525. return 'TWEET'
  526. }
  527.  
  528. /**
  529. * Automatically click the "Show this thread" link to get rid of the "More Tweets" section if the
  530. * user is viewing a tweet from an external link with a ?ref_src= URL.
  531. */
  532. async function hideMoreTweetsSection(path) {
  533. let id = URL_TWEET_ID_RE.exec(path)[1]
  534. let $link = await getElement(`a[href$="/status/${id}"]`, {
  535. name: '"Show this thread" link',
  536. stopIf: pathIsNot(path),
  537. })
  538. if ($link != null) {
  539. log('clicking "Show this thread" link')
  540. $link.click()
  541. }
  542. }
  543.  
  544. /**
  545. * Hides all <aside> or <section> elements which are already in the sidebar.
  546. */
  547. function hideSidebarContents() {
  548. Array.from(
  549. document.querySelectorAll(`${Selectors.SIDEBAR_COLUMN} aside, ${Selectors.SIDEBAR_COLUMN} section`),
  550. hideSidebarElement
  551. )
  552. }
  553.  
  554. /**
  555. * Finds the topmost container for a sidebar content element and hides it.
  556. * @param {HTMLElement} $element
  557. */
  558. function hideSidebarElement($element) {
  559. let $sidebarContainer = $element.parentElement
  560. while (!$sidebarContainer.previousElementSibling) {
  561. $sidebarContainer = $sidebarContainer.parentElement
  562. }
  563. $sidebarContainer.style.display = 'none'
  564. }
  565.  
  566. /**
  567. * Checks if a tweet is preceded by an element creating a vertical reply line.
  568. * @param {HTMLElement} $tweet
  569. * @returns {boolean}
  570. */
  571. function isReplyToPreviousTweet($tweet) {
  572. let $replyLine = $tweet.previousElementSibling?.firstElementChild?.firstElementChild?.firstElementChild
  573. if ($replyLine) {
  574. return getComputedStyle($replyLine).width == '2px'
  575. }
  576. }
  577.  
  578. function onPopup($topLevelElement) {
  579. let $confirmButton = $topLevelElement.querySelector('div[data-testid="confirmationSheetConfirm"]')
  580. // Block button
  581. if ($confirmButton && $confirmButton.innerText == 'Block') {
  582. if (config.fastBlock) {
  583. log('Fast blocking')
  584. $confirmButton.click()
  585. }
  586. return
  587. }
  588. }
  589.  
  590. function onTimelineChange($timeline, page) {
  591. log(`processing ${$timeline.children.length} timeline item${s($timeline.children.length)}`)
  592. /** @type {HTMLElement} */
  593. let $previousItem = null
  594. /** @type {?TimelineItemType} */
  595. let previousItemType = null
  596. /** @type {?boolean} */
  597. let hidPreviousItem = null
  598. for (let $item of $timeline.children) {
  599. /** @type {?TimelineItemType} */
  600. let itemType = null
  601. /** @type {?boolean} */
  602. let hideItem = null
  603. /** @type {?HTMLElement} */
  604. let $tweet = $item.querySelector(Selectors.TWEET)
  605.  
  606. if ($tweet != null) {
  607. itemType = getTweetType($tweet)
  608. if (page == LATEST_TWEETS || page == separatedTweetsTimelineTitle || page == HOME) {
  609. if (isReplyToPreviousTweet($tweet) && hidPreviousItem != null) {
  610. hideItem = hidPreviousItem
  611. itemType = previousItemType
  612. } else {
  613. hideItem = shouldHideTimelineItem(itemType, page)
  614. }
  615. }
  616. }
  617.  
  618. if (itemType == null && config.hideWhoToFollowEtc) {
  619. // "Who to follow", "Follow some Topics" etc. headings
  620. if ($item.querySelector(Selectors.TIMELINE_HEADING)) {
  621. itemType = 'HEADING'
  622. hideItem = true
  623. // Also hide the divider above the heading
  624. if ($previousItem?.innerText == '' && $previousItem.firstElementChild) {
  625. /** @type {HTMLElement} */ ($previousItem.firstElementChild).style.display = 'none'
  626. }
  627. }
  628. }
  629.  
  630. if (itemType == null) {
  631. // Assume a non-identified item following an identified item is related to it
  632. // "Who to follow" users and "Follow some Topics" topics appear in subsequent items
  633. // "Show this thread" and "Show more" links appear in subsequent items
  634. if (previousItemType != null) {
  635. hideItem = hidPreviousItem
  636. itemType = previousItemType
  637. }
  638. // The first item in the timeline is sometimes an empty placeholder <div>
  639. else if ($item !== $timeline.firstElementChild && hideItem == null) {
  640. // We're probably also missing some spacer / divider nodes
  641. log('unhandled timeline item', $item)
  642. }
  643. }
  644.  
  645. if (hideItem !== true &&
  646. config.verifiedAccounts === 'hide' &&
  647. $item.querySelector(Selectors.VERIFIED_TICK)) {
  648. hideItem = true
  649. }
  650.  
  651. if (hideItem != null) {
  652. if (/** @type {HTMLElement} */ ($item.firstElementChild).style.display !== (hideItem ? 'none' : '')) {
  653. /** @type {HTMLElement} */ ($item.firstElementChild).style.display = hideItem ? 'none' : ''
  654. // Log these out as they can't be reliably triggered for testing
  655. if (itemType == 'HEADING' || previousItemType == 'HEADING') {
  656. log(`hid a ${previousItemType == 'HEADING' ? 'post-' : ''}heading item`, $item)
  657. }
  658. }
  659. }
  660.  
  661. if (hideItem !== true &&
  662. config.verifiedAccounts === 'highlight' &&
  663. $item.querySelector(Selectors.VERIFIED_TICK) &&
  664. $item.style.backgroundColor !== 'rgba(29, 161, 242, 0.25)') {
  665. $item.style.backgroundColor = 'rgba(29, 161, 242, 0.25)'
  666. }
  667.  
  668. $previousItem = $item
  669. hidPreviousItem = hideItem
  670. // If we hid a heading, keep hiding everything after it until we hit a tweet
  671. if (!(previousItemType == 'HEADING' && itemType == null)) {
  672. previousItemType = itemType
  673. }
  674. }
  675. }
  676.  
  677. function onTitleChange(title) {
  678. // Ignore any leading notification counts in titles, e.g. '(1) Latest Tweets / Twitter'
  679. let notificationCount = ''
  680. if (TITLE_NOTIFICATION_RE.test(title)) {
  681. notificationCount = TITLE_NOTIFICATION_RE.exec(title)[0]
  682. title = title.replace(TITLE_NOTIFICATION_RE, '')
  683. }
  684.  
  685. let homeLinkWasClicked = homeLinkClicked
  686. homeLinkClicked = false
  687.  
  688. // Ignore Flash of Uninitialised Title when navigating to a page for the first
  689. // time.
  690. if (title == 'Twitter') {
  691. log('ignoring Flash of Uninitialised Title')
  692. return
  693. }
  694.  
  695. // Only allow the same page to re-process if the "Customize your view" dialog
  696. // is currently open.
  697. let newPage = title.split(' / ')[0]
  698. if (newPage == currentPage && location.pathname != '/i/display') {
  699. log('ignoring duplicate title change')
  700. currentNotificationCount = notificationCount
  701. return
  702. }
  703.  
  704. // Stay on the separated tweets timeline when…
  705. if (currentPage == separatedTweetsTimelineTitle &&
  706. // …the title has changed back to the main timeline…
  707. (newPage == LATEST_TWEETS || newPage == HOME) &&
  708. // …the Home nav or Latest Tweets / Home header _wasn't_ clicked and…
  709. !homeLinkWasClicked &&
  710. (
  711. // …the user viewed a photo.
  712. URL_PHOTO_RE.test(location.pathname) ||
  713. // …the user stopped viewing a photo.
  714. URL_PHOTO_RE.test(currentPath) ||
  715. // …the user opened or used the "Customize your view" dialog.
  716. location.pathname == '/i/display' ||
  717. // …the user closed the "Customize your view" dialog.
  718. currentPath == '/i/display' ||
  719. // …the user opened the "Send via Direct Message" dialog.
  720. location.pathname == '/messages/compose' ||
  721. // …the user closed the "Send via Direct Message" dialog.
  722. currentPath == '/messages/compose' ||
  723. // …the user opened the compose Tweet dialog.
  724. location.pathname == '/compose/tweet' ||
  725. // …the user closed the compose Tweet dialog.
  726. currentPath == '/compose/tweet' ||
  727. // …the notification count in the title changed.
  728. notificationCount != currentNotificationCount
  729. )) {
  730. log('ignoring title change on separated tweets timeline')
  731. currentNotificationCount = notificationCount
  732. currentPath = location.pathname
  733. setTitle(separatedTweetsTimelineTitle)
  734. return
  735. }
  736.  
  737. // Assumption: all non-FOUT, non-duplicate title changes are navigation, which
  738. // need the page to be re-processed.
  739.  
  740. currentPage = newPage
  741. currentNotificationCount = notificationCount
  742. currentPath = location.pathname
  743.  
  744. if (currentPage == LATEST_TWEETS || currentPage == HOME) {
  745. lastHomeTimelineTitle = currentPage
  746. }
  747.  
  748. log('processing new page')
  749.  
  750. processCurrentPage()
  751. }
  752.  
  753. function processCurrentPage() {
  754. if (pageObservers.length > 0) {
  755. log(`disconnecting ${pageObservers.length} page observer${s(pageObservers.length)}`)
  756. pageObservers.forEach(observer => observer.disconnect())
  757. pageObservers = []
  758. }
  759.  
  760. if (config.alwaysUseLatestTweets && currentPage == HOME) {
  761. switchToLatestTweets(currentPage)
  762. return
  763. }
  764.  
  765. if (config.retweets == 'separate' || config.quoteTweets == 'separate') {
  766. document.body.classList.toggle('Home', currentPage == HOME)
  767. document.body.classList.toggle('LatestTweets', currentPage == LATEST_TWEETS)
  768. document.body.classList.toggle('SeparatedTweets', currentPage == separatedTweetsTimelineTitle)
  769. if (isOnHomeTimeline()) {
  770. addSeparatedTweetsTimelineHeader(currentPage)
  771. }
  772. }
  773.  
  774. let shouldObserveHomeTimeline = isOnHomeTimeline() && (
  775. config.retweets != 'ignore' || config.quoteTweets != 'ignore' || config.verifiedAccounts != 'ignore' || config.hideWhoToFollowEtc
  776. )
  777. let shouldObserveProfileTimeline = PROFILE_TITLE_RE.test(currentPage) && (
  778. config.verifiedAccounts != 'ignore' || config.hideWhoToFollowEtc
  779. )
  780.  
  781. if (shouldObserveHomeTimeline || shouldObserveProfileTimeline) {
  782. observeTimeline(currentPage)
  783. }
  784.  
  785. if (config.hideSidebarContent && currentPage != MESSAGES) {
  786. observeSidebar(currentPage)
  787. }
  788.  
  789. if (config.hideMoreTweets && URL_TWEET_ID_RE.test(currentPath)) {
  790. let searchParams = new URLSearchParams(location.search)
  791. if (searchParams.has('ref_src') || searchParams.has('s')) {
  792. hideMoreTweetsSection(currentPath)
  793. }
  794. }
  795.  
  796. if (config.pinQuotedTweetOnQuoteTweetsPage && currentPage == QUOTE_TWEETS) {
  797. tweakQuoteTweetsPage()
  798. }
  799. }
  800.  
  801. async function tweakQuoteTweetsPage() {
  802. // Hide the quoted tweet, which is repeated in every quote tweet
  803. let $quoteTweetStyle = addStyle('[data-testid="tweet"] [aria-labelledby] > div:last-child { display: none; }')
  804. pageObservers.push(/** @type {MutationObserver} */ ({disconnect: () => $quoteTweetStyle.remove()}))
  805.  
  806. // Show the quoted tweet once in the pinned header instead
  807. let [$heading, $quotedTweet] = await Promise.all([
  808. getElement(`${Selectors.PRIMARY_COLUMN} ${Selectors.TIMELINE_HEADING}`, {
  809. name: 'Quote Tweets heading',
  810. stopIf: pageIsNot(QUOTE_TWEETS)
  811. }),
  812. getElement('[data-testid="tweet"] [aria-labelledby] > div:last-child', {
  813. name: 'first quoted tweet',
  814. stopIf: pageIsNot(QUOTE_TWEETS)
  815. })
  816. ])
  817.  
  818. if ($heading != null && $quotedTweet != null) {
  819. log('displaying quoted tweet in the Quote Tweets header')
  820. do {
  821. $heading = $heading.parentElement
  822. } while (!$heading.nextElementSibling)
  823.  
  824. let $clone = /** @type {HTMLElement} */ ($quotedTweet.cloneNode(true))
  825. $clone.style.margin = '0 16px 9px 16px'
  826. $heading.insertAdjacentElement('afterend', $clone)
  827. }
  828. }
  829.  
  830. /**
  831. * Sets the page name in <title>, retaining any current notification count.
  832. * @param {string} page
  833. */
  834. function setTitle(page) {
  835. document.title = `${currentNotificationCount}${page} / Twitter`
  836. }
  837.  
  838. /**
  839. * @param {TimelineItemType} type
  840. * @param {string} page
  841. * @returns {boolean}
  842. */
  843. function shouldHideTimelineItem(type, page) {
  844. switch (type) {
  845. case 'RETWEET': return shouldHideSharedTweet(config.retweets, page)
  846. case 'QUOTE_TWEET': return shouldHideSharedTweet(config.quoteTweets, page)
  847. case 'TWEET': return page == separatedTweetsTimelineTitle
  848. default: return true
  849. }
  850. }
  851.  
  852. /**
  853. * @param {SharedTweetsConfig} config
  854. * @param {string} page
  855. * @returns {boolean}
  856. */
  857. function shouldHideSharedTweet(config, page) {
  858. switch (config) {
  859. case 'hide': return true
  860. case 'ignore': return page == separatedTweetsTimelineTitle
  861. case 'separate': return page != separatedTweetsTimelineTitle
  862. }
  863. }
  864.  
  865. async function switchToLatestTweets(page) {
  866. let $switchButton = await getElement('div[aria-label="Top Tweets on"]', {
  867. name: 'timeline switch button',
  868. stopIf: pageIsNot(page),
  869. })
  870.  
  871. if ($switchButton == null) {
  872. return false
  873. }
  874.  
  875. $switchButton.click()
  876.  
  877. let $seeLatestTweetsInstead = await getElement('div[role="menu"] div[role="menuitem"]', {
  878. name: '"See latest Tweets instead" menu item',
  879. stopIf: pageIsNot(page),
  880. })
  881.  
  882. if ($seeLatestTweetsInstead == null) {
  883. return false
  884. }
  885.  
  886. /** @type {HTMLElement} */ ($seeLatestTweetsInstead.closest('div[tabindex="0"]')).click()
  887. return true
  888. }
  889.  
  890. let updateThemeColor = (function() {
  891. let $style = addStyle('')
  892. let lastThemeColor = null
  893.  
  894. return async function updateThemeColor() {
  895. let $tweetButton = await getElement('a[data-testid="SideNav_NewTweet_Button"]', {
  896. name: 'Tweet button'
  897. })
  898.  
  899. let themeColor = getComputedStyle($tweetButton).backgroundColor
  900. if (themeColor === lastThemeColor) {
  901. return
  902. }
  903. log(`setting theme color to ${themeColor}`)
  904. lastThemeColor = themeColor
  905. $style.textContent = [
  906. 'body.Home main h2:not(#tnt_separated_tweets)',
  907. 'body.LatestTweets main h2:not(#tnt_separated_tweets)',
  908. 'body.SeparatedTweets #tnt_separated_tweets',
  909. ].join(', ') + ` { color: ${lastThemeColor}; }`
  910.  
  911. processCurrentPage()
  912. }
  913. })()
  914. //#endregion
  915.  
  916. //#region Main
  917. function main() {
  918. log('config', config)
  919.  
  920. addStaticCss()
  921.  
  922. observeBodyBackgroundColor()
  923.  
  924. if (config.fastBlock) {
  925. observePopups()
  926. }
  927.  
  928. if (config.navBaseFontSize) {
  929. observeHtmlFontSize()
  930. }
  931.  
  932. if (config.retweets == 'separate' || config.quoteTweets == 'separate') {
  933. observeThemeChangeRerenders()
  934. }
  935.  
  936. if (config.hideMoreTweets ||
  937. config.hideSidebarContent ||
  938. config.hideWhoToFollowEtc ||
  939. config.retweets != 'ignore' ||
  940. config.quoteTweets != 'ignore' ||
  941. config.verifiedAccounts != 'ignore') {
  942. configureSeparatedTweetsTimeline()
  943. observeTitle()
  944. }
  945. }
  946.  
  947. if (typeof chrome != 'undefined' && typeof chrome.storage != 'undefined') {
  948. chrome.storage.local.get((storedConfig) => {
  949. Object.assign(config, storedConfig)
  950. main()
  951. })
  952. }
  953. else {
  954. main()
  955. }
  956. //#endregion