with "This Tweet is unavailable."
$tweet.querySelector('article')) {
return 'QUOTE_TWEET'
}
return 'TWEET'
}
/**
* @param {HTMLElement} $popup
* @returns {boolean} false if there was nothing actionable in the popup
*/
function handlePopup($popup) {
if (config.fastBlock) {
if (blockMenuItemSeen && $popup.querySelector('[data-testid="confirmationSheetConfirm"]')) {
log('fast blocking')
;/** @type {HTMLElement} */ ($popup.querySelector('[data-testid="confirmationSheetConfirm"]')).click()
return true
}
else if ($popup.querySelector('[data-testid="block"]')) {
log('preparing for fast blocking')
blockMenuItemSeen = true
// Create a nested observer for mobile, as it reuses the existing popup element
return !mobile
} else {
blockMenuItemSeen = false
}
}
if (config.addAddMutedWordMenuItem) {
let $settingsLink = /** @type {HTMLElement} */ ($popup.querySelector('a[href="/settings"]'))
if ($settingsLink) {
addAddMutedWordMenuItem($settingsLink)
return true
}
}
return false
}
/**
* Automatically click a tweet to get rid of the "More Tweets" section.
*/
async function hideMoreTweetsSection(path) {
let id = URL_TWEET_ID_RE.exec(path)[1]
let $link = await getElement(`a[href$="/status/${id}"]`, {
name: 'tweet',
stopIf: pathIsNot(path),
})
if ($link) {
log('clicking "Show this thread" link')
$link.click()
}
}
/**
* @param {string} page
*/
async function hideOpenAppButton(page) {
let $button = await getElement('header div:nth-of-type(3) > [role="button"]', {
stopIf: pageIsNot(page),
// The header doesn't re-render if you move to another tweet
timeout: 2000,
})
if ($button) {
log('hiding "Open app" button')
// Hide the button directly rather than its parent, as the parent is reused
// for other things - e.g. the sparkles button on the main timeline
$button.style.visibility = 'hidden'
}
}
/**
* 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'
}
}
/**
* @returns {MutationObserver | undefined}
*/
function onPopup($popup) {
log('popup appeared', $popup)
if (handlePopup($popup)) return
log('observing nested popups')
return observeElement($popup, (mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((/** @type {HTMLElement} */ $nestedPopup) => {
log('nested popup appeared', $nestedPopup)
handlePopup($nestedPopup)
})
})
})
}
function onTimelineChange($timeline, page) {
log(`processing ${$timeline.children.length} timeline item${s($timeline.children.length)}`)
/** @type {HTMLElement} */
let $previousItem = null
/** @type {?import("./types").TimelineItemType} */
let previousItemType = null
/** @type {?boolean} */
let hidPreviousItem = null
for (let $item of $timeline.children) {
/** @type {?import("./types").TimelineItemType} */
let itemType = null
/** @type {?boolean} */
let hideItem = null
/** @type {?HTMLElement} */
let $tweet = $item.querySelector(Selectors.TWEET)
if ($tweet != null) {
itemType = getTweetType($tweet)
if (isOnMainTimelinePage()) {
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
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) {
log('title changed', {title: title.split(ltr ? ' / ' : ' \\ ')[ltr ? 0 : 1], path: location.pathname})
// 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 homeNavigationWasUsed = homeNavigationIsBeingUsed
homeNavigationIsBeingUsed = false
if (title == getString('TWITTER')) {
// Mobile uses "Twitter" when viewing a photo - we need to let these process
// so the next page will be re-processed when the photo is closed.
if (mobile && URL_PHOTO_RE.test(location.pathname)) {
log('viewing a photo on mobile')
}
// Ignore Flash of Uninitialised Title when navigating to a page for the
// first time.
else {
log('ignoring Flash of Uninitialised Title')
return
}
}
let newPage = title.split(ltr ? ' / ' : ' \\ ')[ltr ? 0 : 1]
// Only allow the same page to re-process after a title change on desktop if
// the "Customize your view" dialog is currently open.
if (newPage == currentPage && !(desktop && location.pathname == '/i/display')) {
log('ignoring duplicate title change')
currentNotificationCount = notificationCount
return
}
// On desktop, stay on the separated tweets timeline when…
if (desktop && currentPage == separatedTweetsTimelineTitle &&
// …the title has changed back to the main timeline…
(newPage == getString('LATEST_TWEETS') || newPage == getString('HOME')) &&
// …the Home nav link or Latest Tweets / Home header _wasn't_ clicked and…
!homeNavigationWasUsed &&
(
// …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
}
// Restore display of the separated tweets timelne if it's the last one we
// saw, and the user navigated back home without using the Home navigation
// item.
if (location.pathname == PagePaths.HOME &&
currentPath != PagePaths.HOME &&
!homeNavigationWasUsed &&
lastMainTimelineTitle == separatedTweetsTimelineTitle) {
log('restoring display of the 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 (isOnLatestTweetsTimeline() || isOnHomeTimeline()) {
currentMainTimelineType = currentPage
}
if (isOnMainTimelinePage()) {
lastMainTimelineTitle = 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 == getString('HOME')) {
switchToLatestTweets(currentPage)
return
}
// Hooks for styling pages
$body.classList.toggle('Explore', isOnExplorePage())
$body.classList.toggle('HideAppNags', (
mobile && config.hideAppNags && MOBILE_LOGGED_OUT_URLS.includes(currentPath))
)
$body.classList.toggle('HideSidebar', shouldHideSidebar())
$body.classList.toggle('MainTimeline', isOnMainTimelinePage())
$body.classList.toggle('Profile', isOnProfilePage())
$body.classList.toggle('QuoteTweets', isOnQuoteTweetsPage())
$body.classList.toggle('Tweet', isOnIndividualTweetPage())
// "Which version of the main timeline are we on?" hooks for styling
$body.classList.toggle('Home', isOnHomeTimeline())
$body.classList.toggle('LatestTweets', isOnLatestTweetsTimeline())
$body.classList.toggle('SeparatedTweets', isOnSeparateTweetsTimeline())
let shouldObserveTimeline = isOnProfilePage() && (
config.verifiedAccounts != 'ignore' || config.hideWhoToFollowEtc
)
if (isOnMainTimelinePage()) {
shouldObserveTimeline = (
config.retweets != 'ignore' ||
config.quoteTweets != 'ignore' ||
config.verifiedAccounts != 'ignore' ||
config.hideWhoToFollowEtc ||
(currentMainTimelineType == getString('HOME') && (
config.likedTweets != 'ignore' ||
config.repliedToTweets != 'ignore'
))
)
if (shouldObserveTimeline && (config.retweets == 'separate' || config.quoteTweets == 'separate')) {
addSeparatedTweetsTimelineControl(currentPage)
}
} else if (mobile) {
removeMobileTimelineHeaderElements()
}
if (shouldObserveTimeline) {
observeTimeline(currentPage)
}
if (isOnIndividualTweetPage()) {
tweakIndividualTweetPage()
}
if (config.tweakQuoteTweetsPage && isOnQuoteTweetsPage()) {
tweakQuoteTweetsPage()
}
if (mobile && config.hideExplorePageContents && isOnExplorePage()) {
tweakExplorePage(currentPage)
}
}
/**
* The mobile version of Twitter reuses heading elements between screens, so we
* always remove any elements which could be there from the previous page
* and re-add them later when needed.
*/
function removeMobileTimelineHeaderElements() {
document.querySelector('#tnt_shared_tweets_timeline_title')?.remove()
document.querySelector('#tnt_switch_timeline')?.remove()
}
/**
* Sets the page name in
, retaining any current notification count.
* @param {string} page
*/
function setTitle(page) {
document.title = ltr ? (
`${currentNotificationCount}${page} / ${getString('TWITTER')}`
) : (
`${currentNotificationCount}${getString('TWITTER')} \\ ${page}`
)
}
/**
* @param {import("./types").AlgorithmicTweetsConfig} config
* @param {string} page
* @returns {boolean}
*/
function shouldHideAlgorithmicTweet(config, page) {
switch (config) {
case 'hide': return true
case 'ignore': return page == separatedTweetsTimelineTitle
}
}
/**
* @param {import("./types").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
}
}
/**
* @param {import("./types").TimelineItemType} type
* @param {string} page
* @returns {boolean}
*/
function shouldHideTimelineItem(type, page) {
switch (type) {
case 'LIKED': return shouldHideAlgorithmicTweet(config.likedTweets, page)
case 'QUOTE_TWEET': return shouldHideSharedTweet(config.quoteTweets, page)
case 'REPLIED': return shouldHideAlgorithmicTweet(config.repliedToTweets, page)
case 'RETWEET': return shouldHideSharedTweet(config.retweets, page)
case 'TWEET': return page == separatedTweetsTimelineTitle
default: return true
}
}
async function switchToLatestTweets(page) {
log('switching to Latest Tweets timeline')
let contextSelector = mobile ? 'header div:nth-of-type(3)' : Selectors.PRIMARY_COLUMN
let $switchButton = await getElement(`${contextSelector} [role="button"]`, {
name: 'sparkle button',
stopIf: pageIsNot(page),
})
if ($switchButton == null) return
log('clicking sparkle button')
$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
log('clicking "See latest Tweets" instead menu item')
$seeLatestTweetsInstead.click()
}
async function tweakExplorePage(page) {
let $searchInput = await getElement('input[data-testid="SearchBox_Search_Input"]', {
name: 'search input',
stopIf: pageIsNot(page),
})
if (!$searchInput) return
log('focusing search input')
$searchInput.focus()
let $backButton = await getElement('[role="button"]:not([data-testid="DashButton_ProfileIcon_Link"])', {
context: $searchInput.closest('header'),
name: 'back button',
stopIf: pageIsNot(page),
})
if (!$backButton) return
// The back button appears after the search input is focused. When you tap it
// or go back manually, it's replaced with the slide-out menu button and the
// Explore page contents are shown - we want to skip that.
pageObservers.push(
observeElement($backButton.parentElement, (mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((/** @type {HTMLElement} */ $el) => {
if ($el.querySelector('[data-testid="DashButton_ProfileIcon_Link"]')) {
log('slide-out menu button appeared, going back to skip Explore page')
history.go(-2)
}
})
})
})
)
}
async function tweakIndividualTweetPage() {
if (mobile && config.hideAppNags) {
hideOpenAppButton(currentPage)
}
if (config.hideMoreTweets) {
let searchParams = new URLSearchParams(location.search)
if (searchParams.has('ref_src') || searchParams.has('s')) {
hideMoreTweetsSection(currentPath)
}
}
}
async function tweakQuoteTweetsPage() {
if (desktop) {
// 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: not(isOnQuoteTweetsPage)
}),
getElement('[data-testid="tweet"] [aria-labelledby] > div:last-child', {
name: 'first quoted tweet',
stopIf: not(isOnQuoteTweetsPage)
})
])
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)
}
}
}
//#endregion
//#region Main
function main() {
log({config, lang, platform: mobile ? 'mobile' : 'desktop'})
configureSeparatedTweetsTimelineTitle()
addStaticCss()
observeFontSize()
observeBackgroundColor()
observeColor()
observePopups()
observeTitle()
}
if (typeof GM == 'undefined') {
chrome.storage.local.get((storedConfig) => {
Object.assign(config, storedConfig)
main()
})
}
else {
main()
}
//#endregion