您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Reduce algorithmic content on Twitter, hide toxic trends, control which shared tweets appear on your timeline, and improve the UI
当前为
// ==UserScript== // @name Tweak New Twitter // @description Reduce algorithmic content on Twitter, hide toxic trends, control which shared tweets appear on your timeline, and improve the UI // @namespace https://github.com/insin/tweak-new-twitter/ // @match https://twitter.com/* // @match https://mobile.twitter.com/* // @version 28 // ==/UserScript== //#region Custom types /** @typedef {'TWEET'|'QUOTE_TWEET'|'RETWEET'|'PROMOTED_TWEET'|'HEADING'} TimelineItemType */ /** @typedef {'separate'|'hide'|'ignore'} SharedTweetsConfig */ /** @typedef {'highlight'|'hide'|'ignore'} VerifiedAccountsConfig */ //#endregion //#region Config & variables /** * Default config enables all features. * * You'll need to edit the config object manually for now if you're using this * as a user script. */ const config = { alwaysUseLatestTweets: true, fastBlock: true, hideAccountSwitcher: true, hideBookmarksNav: true, hideExploreNav: true, hideListsNav: true, hideMessagesDrawer: true, hideMoreTweets: true, hideSidebarContent: true, hideWhoToFollowEtc: true, navBaseFontSize: true, pinQuotedTweetOnQuoteTweetsPage: true, /** @type {SharedTweetsConfig} */ quoteTweets: 'ignore', /** @type {SharedTweetsConfig} */ retweets: 'separate', /** @type {VerifiedAccountsConfig} */ verifiedAccounts: 'ignore', } // Only for use when developing, not exposed in the options UI config.enableDebugLogging = false // Document title names used by Twitter const HOME = 'Home' const LATEST_TWEETS = 'Latest Tweets' const MESSAGES = 'Messages' const QUOTE_TWEETS = 'Quote Tweets' const PROFILE_TITLE_RE = /\(@[a-z\d_]{1,15}\)$/i const TITLE_NOTIFICATION_RE = /^\(\d+\+?\) / const URL_TWEET_ID_RE = /\/status\/(\d+)$/ const URL_PHOTO_RE = /photo\/\d$/ const Selectors = { MESSAGES_DRAWER: 'div[data-testid="DMDrawer"]', NAV_HOME_LINK: 'a[data-testid="AppTabBar_Home_Link"]', PRIMARY_COLUMN: 'div[data-testid="primaryColumn"]', PRIMARY_NAV: 'nav[aria-label="Primary"]', PROMOTED_TWEET: '[data-testid="placementTracking"]', SIDEBAR_COLUMN: 'div[data-testid="sidebarColumn"]', TIMELINE_HEADING: 'h2[role="heading"]', TWEET: 'div[data-testid="tweet"]', VERIFIED_TICK: 'svg[aria-label="Verified account"]', } Object.assign(Selectors, { TIMELINE: `${Selectors.PRIMARY_COLUMN} section > h1 + div[aria-label] > div`, }) /** Title of the current page, without the ' / Twitter' suffix */ let currentPage = '' /** Notification count in the title (including trailing space), e.g. '(1) ' */ let currentNotificationCount = '' /** Current URL path */ let currentPath = '' /** Flag for a Home / Latest Tweets link having been clicked */ let homeLinkClicked = false /** The last title we saw for the home timeline */ let lastHomeTimelineTitle = '' /** * MutationObservers active on the current page * @type MutationObserver[] */ let pageObservers = [] // Config for the fake timeline used to display retweets/quote tweets separately let separatedTweetsTimelineTitle = '' let separatedTweetsTimelineName = '' function configureSeparatedTweetsTimeline() { if (config.retweets == 'separate' && config.quoteTweets == 'separate') { separatedTweetsTimelineTitle = separatedTweetsTimelineName = 'Shared Tweets' } else if (config.retweets == 'separate') { separatedTweetsTimelineTitle = separatedTweetsTimelineName = 'Retweets' } else if (config.quoteTweets == 'separate') { // Twitter already uses 'Quote Tweets' as a page title // ☠ ¡This string starts with a ZWSP! ☠ separatedTweetsTimelineTitle = separatedTweetsTimelineName = 'Quote Tweets' } } function isOnHomeTimeline() { return currentPage == LATEST_TWEETS || currentPage == separatedTweetsTimelineTitle || currentPage == HOME } //#endregion //#region Utility functions /** * @param {string} css * @return {HTMLStyleElement} */ function addStyle(css) { let $style = document.createElement('style') $style.dataset.insertedBy = 'tweak-new-twitter' $style.textContent = css document.head.appendChild($style) return $style } /** * @param {string} selector * @param {{ * name?: string * stopIf?: () => boolean * context?: Document | HTMLElement * timeout?: number * }} options * @returns {Promise<HTMLElement>} */ function getElement(selector, { name = null, stopIf = null, context = document, timeout = Infinity, } = {}) { return new Promise((resolve) => { let startTime = Date.now() let rafId let timeoutId function stop($element, reason) { if ($element == null) { log(`stopped waiting for ${name || selector} after ${reason}`) } else if (Date.now() > startTime) { log(`${name || selector} appeared after ${Date.now() - startTime}ms`) } if (rafId) { cancelAnimationFrame(rafId) } if (timeoutId) { clearTimeout(timeoutId) } resolve($element) } if (timeout !== Infinity) { timeoutId = setTimeout(stop, timeout, null, `${timeout}ms timeout`) } function queryElement() { let $element = context.querySelector(selector) if ($element) { stop($element) } else if (stopIf?.() === true) { stop(null, 'stopIf condition met') } else { rafId = requestAnimationFrame(queryElement) } } queryElement() }) } function log(...args) { if (config.enableDebugLogging) { console.log(`🧨${currentPage ? `(${currentPage})` : ''}`, ...args) } } /** * Convenience wrapper for the MutationObserver API. * * The callback is called immediately to support using an observer and its * options as a trigger for any change, without looking at MutationRecords. * * @param {Node} $element * @param {MutationCallback} callback * @param {MutationObserverInit} options */ function observeElement($element, callback, options = {childList: true}) { let observer = new MutationObserver(callback) callback([], observer) observer.observe($element, options) return observer } /** * @param {string} page * @returns {() => boolean} */ function pageIsNot(page) { return () => page != currentPage } /** * @param {string} path * @returns {() => boolean} */ function pathIsNot(path) { return () => path != currentPath } /** * @param {number} n * @returns {string} */ function s(n) { return n == 1 ? '' : 's' } //#endregion //#region Global observers /** * When the background setting is changed in "Customize your view", <body>'s * backgroundColor is changed and the app is re-rendered, so we need to * re-process the current page. */ function observeBodyBackgroundColor() { let $body = document.querySelector('body') let lastBackgroundColor = $body.style.backgroundColor log('observing body style attribute for backgroundColor changes') observeElement($body, () => { if ($body.style.backgroundColor != lastBackgroundColor) { lastBackgroundColor = $body.style.backgroundColor log(`body backgroundColor changed - re-processing current page`) processCurrentPage() } }, { attributes: true, attributeFilter: ['style'] }) } /** * When the font size setting is changed in "Customize your view", <html>'s * fontSize is changed, after which we need to update nav font size accordingly. */ function observeHtmlFontSize() { let $html = document.querySelector('html') let $style = addStyle('') let lastFontSize = '' log('observing html style attribute for fontSize changes') observeElement($html, () => { if ($html.style.fontSize != lastFontSize) { lastFontSize = $html.style.fontSize log(`setting nav font size to ${lastFontSize}`) $style.textContent = [ `${Selectors.PRIMARY_NAV} div[dir="auto"] span { font-size: ${lastFontSize}; font-weight: normal; }`, `${Selectors.PRIMARY_NAV} div[dir="auto"] { margin-top: -4px; }` ].join('\n') } }, { attributes: true, attributeFilter: ['style'] }) } async function observePopups() { let $keyboardWrapper = await getElement('[data-at-shortcutkeys]', { name: 'keyboard wrapper', }) log('observing popups') observeElement($keyboardWrapper.previousElementSibling, (mutations) => { mutations.forEach((mutation) => { // The first popup takes another tick to render content mutation.addedNodes.forEach($el => requestAnimationFrame(() => onPopup($el))) }) }) } async function observeThemeChangeRerenders() { let $themeChangeBoundary = await getElement('#react-root > div > div') log('observing theme change re-renders') observeElement($themeChangeBoundary, () => updateThemeColor()) } async function observeTitle() { let $title = await getElement('title', {name: '<title>'}) log('observing <title>') observeElement($title, () => onTitleChange($title.textContent)) } //#endregion //#region Page observers for the current page async function observeSidebar(page) { let $primaryColumn = await getElement(Selectors.PRIMARY_COLUMN, { name: 'primary column', stopIf: pageIsNot(page), }) /** * Hides <aside> or <section> elements as they appear in the sidebar. * @param {MutationRecord[]} mutations */ function sidebarMutationCallback(mutations) { mutations.forEach((mutation) => { mutation.addedNodes.forEach(($el) => { if ($el.nodeType == Node.ELEMENT_NODE && ($el.nodeName == 'ASIDE' || $el.nodeName == 'SECTION')) { hideSidebarElement(/** @type {HTMLElement} */ ($el)) } }) }) } let $sidebarColumn = document.querySelector(Selectors.SIDEBAR_COLUMN) if ($sidebarColumn) { log('observing sidebar') hideSidebarContents() pageObservers.push( observeElement($sidebarColumn, sidebarMutationCallback, {childList: true, subtree: true}) ) } else { log('waiting for sidebar to appear') } pageObservers.push( observeElement($primaryColumn.parentNode, (mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach(($el) => { if (/** @type {HTMLElement} */ ($el).dataset.testid == 'sidebarColumn') { log('sidebar appeared') hideSidebarContents() observeElement($el, sidebarMutationCallback, {childList: true, subtree: true}) } }) }) }) ) } async function observeTimeline(page) { let $timeline = await getElement(Selectors.TIMELINE, { name: 'timeline', stopIf: pageIsNot(page), }) if ($timeline == null) { return } // On 2020-04-03 Twitter switched to a new way of rendering the timeline which replaces an initial // container with the real element which holds timeline tweets and reduces the number of elements // wrapping the timeline. // // v1.9 was released to handle this. // // On 2020-04-05 they switched back to the old method. // // This attempts to support both approaches in case they keeping switching between the two. // The "new" inital timeline element is a placeholder which doesn't have a style attribute // The "old" timeline has 2 wrapper divs which apply padding via the DOM .style object if ($timeline.hasAttribute('style')) { // The "old" timeline is nested one level deeper and the initial container has padding-bottom // <div aria-label="Timeline: Your Home Timeline"> // <div style="padding-bottom: 0px"> <!-- current $timeline --> // <div style="padding-top: ...px; padding-bottom: ...px"> <!-- we want to observe this --> // <div> <!-- tweet elements are at this level --> // ... if ($timeline.style.paddingBottom) { $timeline = /** @type {HTMLElement} */ ($timeline.firstElementChild) log('observing "old" timeline', {$timeline}) } else { log('observing "new" timeline', {$timeline}) } pageObservers.push( observeElement($timeline, () => onTimelineChange($timeline, page)) ) } else { log('waiting for real "new" timeline') let startTime = Date.now() pageObservers.push( observeElement($timeline.parentNode, (mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach(($timeline) => { if (Date.now() > startTime) { log(`"new" timeline appeared after ${Date.now() - startTime}ms`) } log('observing "new" timeline', {$timeline}) pageObservers.push( observeElement($timeline, () => onTimelineChange($timeline, page)) ) }) }) }) ) } } //#endregion //#region Tweak functions async function addSeparatedTweetsTimelineHeader(page) { let $timelineTitle = await getElement('main h2', { name: 'timeline title', stopIf: pageIsNot(page), }) if ($timelineTitle != null && document.querySelector('#tnt_separated_tweets') == null) { log('inserting separated tweets timeline header') let div = document.createElement('div') div.innerHTML = $timelineTitle.parentElement.outerHTML let $retweets = div.firstElementChild $retweets.querySelector('h2').id = 'tnt_separated_tweets' $retweets.querySelector('span').textContent = separatedTweetsTimelineName // This script assumes navigation has occurred when the document title changes, so by changing // the title we effectively fake navigation to a non-existent page representing the sparated // tweets timeline. $retweets.addEventListener('click', () => { if (!document.title.startsWith(separatedTweetsTimelineTitle)) { setTitle(separatedTweetsTimelineTitle) } window.scrollTo({top: 0}) }) $timelineTitle.parentElement.parentElement.insertAdjacentElement('afterend', $retweets) // Go back to the main timeline when the Latest Tweets / Home heading is clicked $timelineTitle.parentElement.addEventListener('click', () => { if (!document.title.startsWith(lastHomeTimelineTitle)) { homeLinkClicked = true setTitle(lastHomeTimelineTitle) } }) // Go back to the main timeline when the Home nav link is clicked document.querySelector(Selectors.NAV_HOME_LINK).addEventListener('click', () => { homeLinkClicked = true if (location.pathname == '/home' && !document.title.startsWith(lastHomeTimelineTitle)) { setTitle(lastHomeTimelineTitle) } }) } } function addStaticCss() { var cssRules = [] var hideCssSelectors = [] if (config.hideSidebarContent) { hideCssSelectors.push( // Sidefooter `${Selectors.SIDEBAR_COLUMN} nav`, // Who to Follow `${Selectors.SIDEBAR_COLUMN} aside`, // What's Happening, Topics to Follow etc. `${Selectors.SIDEBAR_COLUMN} section` ) } if (config.hideExploreNav) { hideCssSelectors.push(`${Selectors.PRIMARY_NAV} a[href="/explore"]`) } if (config.hideBookmarksNav) { hideCssSelectors.push(`${Selectors.PRIMARY_NAV} a[href="/i/bookmarks"]`) } if (config.hideListsNav) { hideCssSelectors.push(`${Selectors.PRIMARY_NAV} a[href*="/lists"]`) } if (config.hideAccountSwitcher) { cssRules.push(` header[role="banner"] > div > div > div > div:last-child { flex-shrink: 1 !important; align-items: flex-end !important; } [data-testid="SideNav_AccountSwitcher_Button"] > div:first-child, [data-testid="SideNav_AccountSwitcher_Button"] > div:first-child + div { display: none !important; } `) } if (config.hideMessagesDrawer) { hideCssSelectors.push(Selectors.MESSAGES_DRAWER) } if (hideCssSelectors.length > 0) { cssRules.push(`${hideCssSelectors.join(', ')} { display: none !important; }`) } if (cssRules.length > 0) { addStyle(cssRules.join('\n')) } } /** * Attempts to determine the type of a timeline Tweet given the element with data-testid="tweet" on * it, falling back to TWEET if it doesn't appear to be one of the particular types we care about. * @param {HTMLElement} $tweet * @returns {TimelineItemType} */ function getTweetType($tweet) { if ($tweet.closest(Selectors.PROMOTED_TWEET)) { return 'PROMOTED_TWEET' } if ($tweet.previousElementSibling?.textContent.includes('Retweeted')) { return 'RETWEET' } if ($tweet.querySelector('div[id^="id__"] > div[dir="auto"] > span')?.textContent.includes('Quote Tweet') || // QTs of accounts you blocked are displayed as a nested <article> with "This Tweet is unavailable." $tweet.querySelector('article')) { return 'QUOTE_TWEET' } return 'TWEET' } /** * Automatically click the "Show this thread" link to get rid of the "More Tweets" section if the * user is viewing a tweet from an external link with a ?ref_src= URL. */ async function hideMoreTweetsSection(path) { let id = URL_TWEET_ID_RE.exec(path)[1] let $link = await getElement(`a[href$="/status/${id}"]`, { name: '"Show this thread" link', stopIf: pathIsNot(path), }) if ($link != null) { log('clicking "Show this thread" link') $link.click() } } /** * Hides all <aside> or <section> elements which are already in the sidebar. */ function hideSidebarContents() { Array.from( document.querySelectorAll(`${Selectors.SIDEBAR_COLUMN} aside, ${Selectors.SIDEBAR_COLUMN} section`), hideSidebarElement ) } /** * Finds the topmost container for a sidebar content element and hides it. * @param {HTMLElement} $element */ function hideSidebarElement($element) { let $sidebarContainer = $element.parentElement while (!$sidebarContainer.previousElementSibling) { $sidebarContainer = $sidebarContainer.parentElement } $sidebarContainer.style.display = 'none' } /** * Checks if a tweet is preceded by an element creating a vertical reply line. * @param {HTMLElement} $tweet * @returns {boolean} */ function isReplyToPreviousTweet($tweet) { let $replyLine = $tweet.previousElementSibling?.firstElementChild?.firstElementChild?.firstElementChild if ($replyLine) { return getComputedStyle($replyLine).width == '2px' } } function onPopup($topLevelElement) { let $confirmButton = $topLevelElement.querySelector('div[data-testid="confirmationSheetConfirm"]') // Block button if ($confirmButton && $confirmButton.innerText == 'Block') { if (config.fastBlock) { log('Fast blocking') $confirmButton.click() } return } } function onTimelineChange($timeline, page) { log(`processing ${$timeline.children.length} timeline item${s($timeline.children.length)}`) /** @type {HTMLElement} */ let $previousItem = null /** @type {?TimelineItemType} */ let previousItemType = null /** @type {?boolean} */ let hidPreviousItem = null for (let $item of $timeline.children) { /** @type {?TimelineItemType} */ let itemType = null /** @type {?boolean} */ let hideItem = null /** @type {?HTMLElement} */ let $tweet = $item.querySelector(Selectors.TWEET) if ($tweet != null) { itemType = getTweetType($tweet) if (page == LATEST_TWEETS || page == separatedTweetsTimelineTitle || page == HOME) { if (isReplyToPreviousTweet($tweet) && hidPreviousItem != null) { hideItem = hidPreviousItem itemType = previousItemType } else { hideItem = shouldHideTimelineItem(itemType, page) } } } if (itemType == null && config.hideWhoToFollowEtc) { // "Who to follow", "Follow some Topics" etc. headings if ($item.querySelector(Selectors.TIMELINE_HEADING)) { itemType = 'HEADING' hideItem = true // Also hide the divider above the heading if ($previousItem?.innerText == '' && $previousItem.firstElementChild) { /** @type {HTMLElement} */ ($previousItem.firstElementChild).style.display = 'none' } } } if (itemType == null) { // Assume a non-identified item following an identified item is related to it // "Who to follow" users and "Follow some Topics" topics appear in subsequent items // "Show this thread" and "Show more" links appear in subsequent items if (previousItemType != null) { hideItem = hidPreviousItem itemType = previousItemType } // The first item in the timeline is sometimes an empty placeholder <div> else if ($item !== $timeline.firstElementChild && hideItem == null) { // We're probably also missing some spacer / divider nodes log('unhandled timeline item', $item) } } if (hideItem !== true && config.verifiedAccounts === 'hide' && $item.querySelector(Selectors.VERIFIED_TICK)) { hideItem = true } if (hideItem != null) { if (/** @type {HTMLElement} */ ($item.firstElementChild).style.display !== (hideItem ? 'none' : '')) { /** @type {HTMLElement} */ ($item.firstElementChild).style.display = hideItem ? 'none' : '' // Log these out as they can't be reliably triggered for testing if (itemType == 'HEADING' || previousItemType == 'HEADING') { log(`hid a ${previousItemType == 'HEADING' ? 'post-' : ''}heading item`, $item) } } } if (hideItem !== true && config.verifiedAccounts === 'highlight' && $item.querySelector(Selectors.VERIFIED_TICK) && $item.style.backgroundColor !== 'rgba(29, 161, 242, 0.25)') { $item.style.backgroundColor = 'rgba(29, 161, 242, 0.25)' } $previousItem = $item hidPreviousItem = hideItem // If we hid a heading, keep hiding everything after it until we hit a tweet if (!(previousItemType == 'HEADING' && itemType == null)) { previousItemType = itemType } } } function onTitleChange(title) { // Ignore any leading notification counts in titles, e.g. '(1) Latest Tweets / Twitter' let notificationCount = '' if (TITLE_NOTIFICATION_RE.test(title)) { notificationCount = TITLE_NOTIFICATION_RE.exec(title)[0] title = title.replace(TITLE_NOTIFICATION_RE, '') } let homeLinkWasClicked = homeLinkClicked homeLinkClicked = false // Ignore Flash of Uninitialised Title when navigating to a page for the first // time. if (title == 'Twitter') { log('ignoring Flash of Uninitialised Title') return } // Only allow the same page to re-process if the "Customize your view" dialog // is currently open. let newPage = title.split(' / ')[0] if (newPage == currentPage && location.pathname != '/i/display') { log('ignoring duplicate title change') currentNotificationCount = notificationCount return } // Stay on the separated tweets timeline when… if (currentPage == separatedTweetsTimelineTitle && // …the title has changed back to the main timeline… (newPage == LATEST_TWEETS || newPage == HOME) && // …the Home nav or Latest Tweets / Home header _wasn't_ clicked and… !homeLinkWasClicked && ( // …the user viewed a photo. URL_PHOTO_RE.test(location.pathname) || // …the user stopped viewing a photo. URL_PHOTO_RE.test(currentPath) || // …the user opened or used the "Customize your view" dialog. location.pathname == '/i/display' || // …the user closed the "Customize your view" dialog. currentPath == '/i/display' || // …the user opened the "Send via Direct Message" dialog. location.pathname == '/messages/compose' || // …the user closed the "Send via Direct Message" dialog. currentPath == '/messages/compose' || // …the user opened the compose Tweet dialog. location.pathname == '/compose/tweet' || // …the user closed the compose Tweet dialog. currentPath == '/compose/tweet' || // …the notification count in the title changed. notificationCount != currentNotificationCount )) { log('ignoring title change on separated tweets timeline') currentNotificationCount = notificationCount currentPath = location.pathname setTitle(separatedTweetsTimelineTitle) return } // Assumption: all non-FOUT, non-duplicate title changes are navigation, which // need the page to be re-processed. currentPage = newPage currentNotificationCount = notificationCount currentPath = location.pathname if (currentPage == LATEST_TWEETS || currentPage == HOME) { lastHomeTimelineTitle = currentPage } log('processing new page') processCurrentPage() } function processCurrentPage() { if (pageObservers.length > 0) { log(`disconnecting ${pageObservers.length} page observer${s(pageObservers.length)}`) pageObservers.forEach(observer => observer.disconnect()) pageObservers = [] } if (config.alwaysUseLatestTweets && currentPage == HOME) { switchToLatestTweets(currentPage) return } if (config.retweets == 'separate' || config.quoteTweets == 'separate') { document.body.classList.toggle('Home', currentPage == HOME) document.body.classList.toggle('LatestTweets', currentPage == LATEST_TWEETS) document.body.classList.toggle('SeparatedTweets', currentPage == separatedTweetsTimelineTitle) if (isOnHomeTimeline()) { addSeparatedTweetsTimelineHeader(currentPage) } } let shouldObserveHomeTimeline = isOnHomeTimeline() && ( config.retweets != 'ignore' || config.quoteTweets != 'ignore' || config.verifiedAccounts != 'ignore' || config.hideWhoToFollowEtc ) let shouldObserveProfileTimeline = PROFILE_TITLE_RE.test(currentPage) && ( config.verifiedAccounts != 'ignore' || config.hideWhoToFollowEtc ) if (shouldObserveHomeTimeline || shouldObserveProfileTimeline) { observeTimeline(currentPage) } if (config.hideSidebarContent && currentPage != MESSAGES) { observeSidebar(currentPage) } if (config.hideMoreTweets && URL_TWEET_ID_RE.test(currentPath)) { let searchParams = new URLSearchParams(location.search) if (searchParams.has('ref_src') || searchParams.has('s')) { hideMoreTweetsSection(currentPath) } } if (config.pinQuotedTweetOnQuoteTweetsPage && currentPage == QUOTE_TWEETS) { tweakQuoteTweetsPage() } } async function tweakQuoteTweetsPage() { // Hide the quoted tweet, which is repeated in every quote tweet let $quoteTweetStyle = addStyle('[data-testid="tweet"] [aria-labelledby] > div:last-child { display: none; }') pageObservers.push(/** @type {MutationObserver} */ ({disconnect: () => $quoteTweetStyle.remove()})) // Show the quoted tweet once in the pinned header instead let [$heading, $quotedTweet] = await Promise.all([ getElement(`${Selectors.PRIMARY_COLUMN} ${Selectors.TIMELINE_HEADING}`, { name: 'Quote Tweets heading', stopIf: pageIsNot(QUOTE_TWEETS) }), getElement('[data-testid="tweet"] [aria-labelledby] > div:last-child', { name: 'first quoted tweet', stopIf: pageIsNot(QUOTE_TWEETS) }) ]) if ($heading != null && $quotedTweet != null) { log('displaying quoted tweet in the Quote Tweets header') do { $heading = $heading.parentElement } while (!$heading.nextElementSibling) let $clone = /** @type {HTMLElement} */ ($quotedTweet.cloneNode(true)) $clone.style.margin = '0 16px 9px 16px' $heading.insertAdjacentElement('afterend', $clone) } } /** * Sets the page name in <title>, retaining any current notification count. * @param {string} page */ function setTitle(page) { document.title = `${currentNotificationCount}${page} / Twitter` } /** * @param {TimelineItemType} type * @param {string} page * @returns {boolean} */ function shouldHideTimelineItem(type, page) { switch (type) { case 'RETWEET': return shouldHideSharedTweet(config.retweets, page) case 'QUOTE_TWEET': return shouldHideSharedTweet(config.quoteTweets, page) case 'TWEET': return page == separatedTweetsTimelineTitle default: return true } } /** * @param {SharedTweetsConfig} config * @param {string} page * @returns {boolean} */ function shouldHideSharedTweet(config, page) { switch (config) { case 'hide': return true case 'ignore': return page == separatedTweetsTimelineTitle case 'separate': return page != separatedTweetsTimelineTitle } } async function switchToLatestTweets(page) { let $switchButton = await getElement('div[aria-label="Top Tweets on"]', { name: 'timeline switch button', stopIf: pageIsNot(page), }) if ($switchButton == null) { return false } $switchButton.click() let $seeLatestTweetsInstead = await getElement('div[role="menu"] div[role="menuitem"]', { name: '"See latest Tweets instead" menu item', stopIf: pageIsNot(page), }) if ($seeLatestTweetsInstead == null) { return false } /** @type {HTMLElement} */ ($seeLatestTweetsInstead.closest('div[tabindex="0"]')).click() return true } let updateThemeColor = (function() { let $style = addStyle('') let lastThemeColor = null return async function updateThemeColor() { let $tweetButton = await getElement('a[data-testid="SideNav_NewTweet_Button"]', { name: 'Tweet button' }) let themeColor = getComputedStyle($tweetButton).backgroundColor if (themeColor === lastThemeColor) { return } log(`setting theme color to ${themeColor}`) lastThemeColor = themeColor $style.textContent = [ 'body.Home main h2:not(#tnt_separated_tweets)', 'body.LatestTweets main h2:not(#tnt_separated_tweets)', 'body.SeparatedTweets #tnt_separated_tweets', ].join(', ') + ` { color: ${lastThemeColor}; }` processCurrentPage() } })() //#endregion //#region Main function main() { log('config', config) addStaticCss() observeBodyBackgroundColor() if (config.fastBlock) { observePopups() } if (config.navBaseFontSize) { observeHtmlFontSize() } if (config.retweets == 'separate' || config.quoteTweets == 'separate') { observeThemeChangeRerenders() } if (config.hideMoreTweets || config.hideSidebarContent || config.hideWhoToFollowEtc || config.retweets != 'ignore' || config.quoteTweets != 'ignore' || config.verifiedAccounts != 'ignore') { configureSeparatedTweetsTimeline() observeTitle() } } if (typeof chrome != 'undefined' && typeof chrome.storage != 'undefined') { chrome.storage.local.get((storedConfig) => { Object.assign(config, storedConfig) main() }) } else { main() } //#endregion