control-panel-for-twitter/options.js
Jonny Buchanan 6911f9143f Release v4.12.3
- Fixed Discover More Tweets not being hidden when they render before the Discover More heading
- Fixed Premium blue check Tweets the focused Tweet is a reply to being hidden
- Fixed processing blue checks in the Relevant people box when hiding all other sidebar content
2025-06-14 13:48:13 +10:00

623 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

document.title = chrome.i18n.getMessage(`extensionName`)
for (let optionValue of [
'1000',
'10000',
'100000',
'1000000',
]) {
for (let $option of document.querySelectorAll(`option[value="${optionValue}"]`)) {
$option.textContent = formatFollowerCount(Number(optionValue))
}
}
for (let optionValue of [
'badges',
'comfortable',
'compact',
'default',
'hide',
'ignore',
'liked',
'recent',
'relevant',
'separate',
]) {
let label = chrome.i18n.getMessage(`option_${optionValue}`)
for (let $option of document.querySelectorAll(`option[value="${optionValue}"]`)) {
$option.textContent = label
}
}
for (let translationId of [
'addAddMutedWordMenuItemLabel_desktop',
'addAddMutedWordMenuItemLabel_mobile',
'alwaysUseLatestTweetsLabel',
'customCssLabel',
'debugInfo',
'debugLabel',
'debugLogTimelineStatsLabel',
'debugOptionsLabel',
'defaultToLatestSearchLabel',
'disableHomeTimelineInfo',
'disableHomeTimelineLabel',
'disableTweetTextFormattingLabel',
'disabledHomeTimelineRedirectLabel',
'disabledHomeTimelineRedirectOption_messages',
'dontUseChirpFontLabel',
'dropdownMenuFontWeightLabel',
'enabled',
'experimentsOptionsLabel',
'exportConfigLabel',
'fastBlockLabel',
'followButtonStyleLabel',
'followButtonStyleOption_monochrome',
'followButtonStyleOption_themed',
'fullWidthContentInfo',
'fullWidthContentLabel',
'fullWidthMediaLabel',
'hideAccountSwitcherLabel',
'hideAdsNavLabel',
'hideAllMetricsLabel',
'hideBookmarkButtonLabel',
'hideBookmarkMetricsLabel',
'hideComposeTweetLabel',
'hideDiscoverSuggestionsLabel',
'hideExploreNavLabel',
'hideExploreNavWithSidebarLabel',
'hideExplorePageContentsLabel',
'hideFollowingMetricsLabel',
'hideForYouTimelineLabel',
'hideGrokLabel',
'hideGrokTweetsLabel',
'hideInlinePrompts',
'hideJobsLabel',
'hideLikeMetricsLabel',
'hideLiveBroadcastBarLabel',
'hideLiveBroadcastsLabel',
'hideMessagesBottomNavItemLabel',
'hideMessagesDrawerLabel',
'hideMetricsLabel',
'hideMonetizationNavLabel',
'hideMoreSlideOutMenuItemsOptionsLabel_desktop',
'hideMoreSlideOutMenuItemsOptionsLabel_mobile',
'hideProfileHeaderMetricsLabel',
'hideProfileRetweetsLabel',
'hideQuoteTweetMetricsLabel',
'hideReplyMetricsLabel',
'hideRetweetMetricsLabel',
'hideSeeNewTweetsLabel',
'hideShareTweetButtonLabel',
'hideSidebarContentLabel',
'hideSpacesNavLabel',
'hideSubscriptionsLabel',
'hideSuggestedFollowsLabel',
'hideTimelineTweetBoxLabel',
'hideToggleNavigationLabel',
'hideTweetAnalyticsLinksLabel',
'hideTwitterBlueRepliesLabel',
'hideTwitterBlueUpsellsLabel',
'hideUnavailableQuoteTweetsLabel',
'hideUnusedUiItemsOptionsLabel',
'hideVerifiedNotificationsTabLabel',
'hideViewsLabel',
'hideWhatsHappeningLabel',
'hideWhoToFollowEtcLabel',
'homeTimelineOptionsLabel',
'listRetweetsLabel',
'mutableQuoteTweetsLabel',
'navBaseFontSizeLabel',
'navDensityLabel',
'preventNextVideoAutoplayInfo',
'preventNextVideoAutoplayLabel',
'quoteTweetsLabel',
'redirectToTwitterLabel',
'reduceAlgorithmicContentOptionsLabel',
'reduceEngagementOptionsLabel',
'reducedInteractionModeInfo',
'reducedInteractionModeLabel',
'replaceLogoLabel',
'restoreLinkHeadlinesLabel',
'restoreOtherInteractionLinksLabel',
'restoreQuoteTweetsLinkLabel',
'restoreTweetSourceLabel',
'retweetsLabel',
'showBlueReplyFollowersCountAmountLabel',
'showBookmarkButtonUnderFocusedTweetsLabel',
'showPremiumReplyBusinessLabel',
'showPremiumReplyFollowedByLabel',
'showPremiumReplyFollowingLabel',
'showPremiumReplyGovernmentLabel',
'showRelevantPeopleLabel',
'sidebarLabel',
'sortRepliesLabel',
'tweakNewLayoutInfo',
'tweakNewLayoutLabel',
'tweakQuoteTweetsPageLabel',
'twitterBlueChecksLabel',
'twitterBlueChecksOption_replace',
'uiImprovementsOptionsLabel',
'uiTweaksOptionsLabel',
'unblurSensitiveContentLabel',
'uninvertFollowButtonsLabel',
'xFixesLabel',
]) {
document.getElementById(translationId).textContent = chrome.i18n.getMessage(translationId)
}
for (let translationClass of [
'hideBookmarksNavLabel',
'hideCommunitiesNavLabel',
'hideListsNavLabel',
'notificationsLabel',
'saveAndApplyButton',
]) {
let translation = chrome.i18n.getMessage(translationClass)
for (let $el of document.querySelectorAll(`.${translationClass}`)) {
$el.textContent = translation
}
}
for (let amount of [1_000, 10_000, 100_000, 1_000_000]) {
document.querySelector(`option[value="${amount}"]`).textContent = formatFollowerCount(amount)
}
/** @type {boolean} */
let desktop
/** @type {boolean} */
let mobile
const $body = document.body
if (navigator.userAgent.includes('Safari/') && !/Chrom(e|ium)\//.test(navigator.userAgent)) {
$body.classList.add('safari', /iP(ad|hone)/.test(navigator.userAgent) ? 'iOS' : 'macOS')
} else {
$body.classList.toggle('edge', navigator.userAgent.includes('Edg/'))
}
//#region Default config
/** @type {import("./types").Config} */
const defaultConfig = {
enabled: true,
debug: false,
debugLogTimelineStats: false,
// Default based on the platform if the main script hasn't run on Twitter yet
version: /(Android|iP(ad|hone))/.test(navigator.userAgent) ? 'mobile' : 'desktop',
// Shared
addAddMutedWordMenuItem: true,
alwaysUseLatestTweets: true,
defaultToLatestSearch: false,
disableHomeTimeline: false,
disabledHomeTimelineRedirect: 'notifications',
disableTweetTextFormatting: false,
dontUseChirpFont: false,
dropdownMenuFontWeight: true,
fastBlock: true,
followButtonStyle: 'monochrome',
hideAdsNav: true,
hideBookmarkButton: false,
hideBookmarkMetrics: true,
hideBookmarksNav: false,
hideCommunitiesNav: false,
hideComposeTweet: false,
hideExplorePageContents: true,
hideFollowingMetrics: true,
hideForYouTimeline: true,
hideGrokNav: true,
hideGrokTweets: false,
hideInlinePrompts: true,
hideJobsNav: true,
hideLikeMetrics: true,
hideListsNav: false,
hideMetrics: false,
hideMonetizationNav: true,
hideMoreTweets: true,
hideNotifications: 'ignore',
hideProfileRetweets: false,
hideQuoteTweetMetrics: true,
hideQuotesFrom: [],
hideReplyMetrics: true,
hideRetweetMetrics: true,
hideSeeNewTweets: false,
hideShareTweetButton: false,
hideSubscriptions: true,
hideTotalTweetsMetrics: true,
hideTweetAnalyticsLinks: false,
hideTwitterBlueReplies: false,
hideTwitterBlueUpsells: true,
hideUnavailableQuoteTweets: true,
hideVerifiedNotificationsTab: true,
hideViews: true,
hideWhoToFollowEtc: true,
listRetweets: 'ignore',
mutableQuoteTweets: true,
mutedQuotes: [],
quoteTweets: 'ignore',
redirectToTwitter: false,
reducedInteractionMode: false,
replaceLogo: true,
restoreLinkHeadlines: true,
restoreOtherInteractionLinks: false,
restoreQuoteTweetsLink: true,
restoreTweetSource: true,
retweets: 'separate',
showBlueReplyFollowersCount: false,
showBlueReplyFollowersCountAmount: '1000000',
showBookmarkButtonUnderFocusedTweets: true,
showPremiumReplyBusiness: true,
showPremiumReplyFollowedBy: true,
showPremiumReplyFollowing: true,
showPremiumReplyGovernment: true,
sortReplies: 'relevant',
tweakNewLayout: false,
tweakQuoteTweetsPage: true,
twitterBlueChecks: 'replace',
uninvertFollowButtons: true,
unblurSensitiveContent: false,
// Experiments
customCss: '',
// Desktop only
fullWidthContent: false,
fullWidthMedia: true,
hideAccountSwitcher: false,
hideExploreNav: true,
hideExploreNavWithSidebar: true,
hideLiveBroadcasts: false,
hideMessagesDrawer: true,
hideSidebarContent: true,
hideSpacesNav: false,
hideSuggestedFollows: false,
hideTimelineTweetBox: false,
hideToggleNavigation: false,
hideWhatsHappening: false,
navBaseFontSize: true,
navDensity: 'default',
showRelevantPeople: false,
// Mobile only
hideLiveBroadcastBar: false,
hideMessagesBottomNavItem: false,
preventNextVideoAutoplay: true,
}
//#endregion
//#region Config & variables
/**
* Complete configuration for the options page.
* @type {import("./types").Config}
*/
let optionsConfig
/**
* Checkbox group configuration for the version being used (mobile or desktop).
* @type {Map<string, string[]>}
*/
let checkboxGroups
// Page elements
let $experiments = /** @type {HTMLDetailsElement} */ (document.querySelector('details#experiments'))
let $exportConfig = document.querySelector('#export-config')
let $form = document.querySelector('form')
let $hideQuotesFrom = /** @type {HTMLDivElement} */ (document.querySelector('#hideQuotesFrom'))
let $hideQuotesFromDetails = /** @type {HTMLDetailsElement} */ (document.querySelector('details#hideQuotesFromDetails'))
let $hideQuotesFromLabel = /** @type {HTMLElement} */ (document.querySelector('#hideQuotesFromLabel'))
let $mutedQuotes = /** @type {HTMLDivElement} */ (document.querySelector('#mutedQuotes'))
let $mutedQuotesDetails = /** @type {HTMLDetailsElement} */ (document.querySelector('details#mutedQuotesDetails'))
let $mutedQuotesLabel = /** @type {HTMLElement} */ (document.querySelector('#mutedQuotesLabel'))
let $saveCustomCssButton = document.querySelector('button#saveCustomCss')
let $showBlueReplyFollowersCountLabel = /** @type {HTMLElement} */ (document.querySelector('#showBlueReplyFollowersCountLabel'))
//#endregion
//#region Utility functions
function exportConfig() {
let $a = document.createElement('a')
$a.download = 'control-panel-for-twitter-v4.12.3.config.txt'
$a.href = URL.createObjectURL(new Blob([
JSON.stringify(optionsConfig, null, 2)
], {type: 'text/plain'}))
$a.click()
URL.revokeObjectURL($a.href)
}
function formatFollowerCount(num) {
let numFormat = Intl.NumberFormat(undefined, {notation: 'compact', compactDisplay: num < 1_000_000 ? 'short' : 'long'})
return numFormat.format(num)
}
/**
* @param {keyof HTMLElementTagNameMap} tagName
* @param {({[key: string]: any} | null)?} attributes
* @param {...any} children
* @returns {HTMLElement}
*/
function h(tagName, attributes, ...children) {
let $el = document.createElement(tagName)
if (attributes) {
for (let [prop, value] of Object.entries(attributes)) {
if (prop.startsWith('on') && typeof value == 'function') {
$el.addEventListener(prop.slice(2).toLowerCase(), value)
} else {
$el[prop] = value
}
}
}
for (let child of children) {
if (child == null || child === false) continue
if (child instanceof Node) {
$el.appendChild(child)
} else {
$el.insertAdjacentText('beforeend', String(child))
}
}
return $el
}
//#endregion
//#region Options page functions
/**
* Update the options page to match the current config.
*/
function applyConfig() {
mobile = optionsConfig.version == 'mobile'
desktop = !mobile
$body.classList.toggle('mobile', mobile)
$body.classList.toggle('desktop', desktop)
checkboxGroups = new Map(Object.entries({
hideAllMetrics: [
'hideBookmarkMetrics',
'hideFollowingMetrics',
'hideLikeMetrics',
'hideReplyMetrics',
'hideRetweetMetrics',
'hideQuoteTweetMetrics',
'hideTotalTweetsMetrics',
]
}))
updateFormControls()
updateCheckboxGroups()
updateDisplay()
}
/**
* @param {Event} e
*/
function onFormChanged(e) {
if (e.target instanceof HTMLTextAreaElement) return
/** @type {Partial<import("./types").Config>} */
let changedConfig = {}
let $el = /** @type {HTMLInputElement} */ (e.target)
if ($el.type == 'checkbox') {
if (checkboxGroups.has($el.name)) {
checkboxGroups.get($el.name).forEach(checkboxName => {
optionsConfig[checkboxName] = changedConfig[checkboxName] = $el.checked
updateFormControl($form.elements[checkboxName], $el.checked)
})
$el.indeterminate = false
} else {
optionsConfig[$el.name] = changedConfig[$el.name] = $el.checked
// Don't try to redirect the Home timeline to Notifications if both are disabled
if ($el.name == 'hideNotifications' &&
$el.checked &&
optionsConfig.disabledHomeTimelineRedirect == 'notifications') {
$form.elements['disabledHomeTimelineRedirect'].value = 'messages'
optionsConfig.disabledHomeTimelineRedirect = 'messages'
changedConfig.disabledHomeTimelineRedirect = 'messages'
}
updateCheckboxGroups()
}
} else {
optionsConfig[$el.name] = changedConfig[$el.name] = $el.value
}
updateDisplay()
storeConfigChanges(changedConfig)
}
/**
* @param {{[key: string]: chrome.storage.StorageChange}} changes
*/
function onStorageChanged(changes) {
let configChanges = Object.fromEntries(
Object.entries(changes).map(([key, {newValue}]) => [key, newValue])
)
Object.assign(optionsConfig, configChanges)
applyConfig()
}
function saveCustomCss() {
if (optionsConfig.customCss == $form.elements['customCss'].value) return
/** @type {Partial<import("./types").Config>} */
let changedConfig = {}
optionsConfig['customCss'] = changedConfig['customCss'] = $form.elements['customCss'].value
storeConfigChanges(changedConfig)
}
function shouldDisplayHideQuotesFrom() {
return optionsConfig.mutableQuoteTweets && optionsConfig.hideQuotesFrom.length > 0
}
function shouldDisplayMutedQuotes() {
return optionsConfig.mutableQuoteTweets && optionsConfig.mutedQuotes.length > 0
}
/**
* @param {Partial<import("./types").Config>} changes
*/
function storeConfigChanges(changes) {
chrome.storage.onChanged.removeListener(onStorageChanged)
chrome.storage.local.set(changes, () => {
chrome.storage.onChanged.addListener(onStorageChanged)
})
}
function updateCheckboxGroups() {
for (let [group, checkboxNames] of checkboxGroups.entries()) {
let checkedCount = checkboxNames.filter(name => optionsConfig[name]).length
$form.elements[group].checked = checkedCount == checkboxNames.length
$form.elements[group].indeterminate = checkedCount > 0 && checkedCount < checkboxNames.length;
}
}
function updateDisplay() {
$body.classList.toggle('debugging', optionsConfig.debug)
$body.classList.toggle('chronological', optionsConfig.alwaysUseLatestTweets)
$body.classList.toggle('disabled', !optionsConfig.enabled)
$body.classList.toggle('disabledHomeTimeline', optionsConfig.disableHomeTimeline)
$body.classList.toggle('fullWidthContent', optionsConfig.fullWidthContent)
$body.classList.toggle('hidingBookmarkButton', optionsConfig.hideBookmarkButton)
$body.classList.toggle('hidingExploreNav', optionsConfig.hideExploreNav)
$body.classList.toggle('hidingMetrics', optionsConfig.hideMetrics)
$body.classList.toggle('hidingNotifications', optionsConfig.hideNotifications == 'hide')
$body.classList.toggle('hidingQuotesFrom', shouldDisplayHideQuotesFrom())
$body.classList.toggle('hidingSuggestedFollows', optionsConfig.hideSidebarContent || optionsConfig.hideSuggestedFollows)
$body.classList.toggle('hidingTwitterBlueReplies', optionsConfig.hideTwitterBlueReplies)
$body.classList.toggle('mutingQuotes', shouldDisplayMutedQuotes())
$body.classList.toggle('showingBlueReplyFollowersCount', optionsConfig.showBlueReplyFollowersCount)
$body.classList.toggle('showingSidebarContent', !optionsConfig.hideSidebarContent)
$body.classList.toggle('tweakingNewLayout', optionsConfig.tweakNewLayout)
$body.classList.toggle('uninvertedFollowButtons', optionsConfig.uninvertFollowButtons)
$showBlueReplyFollowersCountLabel.textContent = chrome.i18n.getMessage(
'showBlueReplyFollowersCountLabel',
formatFollowerCount(Number(optionsConfig.showBlueReplyFollowersCountAmount))
)
updateHideQuotesFromDisplay()
updateMutedQuotesDisplay()
}
function updateHideQuotesFromDisplay() {
if (!shouldDisplayHideQuotesFrom()) return
$hideQuotesFromLabel.textContent = chrome.i18n.getMessage('hideQuotesFromLabel', String(optionsConfig.hideQuotesFrom.length))
if (!$hideQuotesFromDetails.open) return
while ($hideQuotesFrom.hasChildNodes()) $hideQuotesFrom.firstChild.remove()
for (let user of optionsConfig.hideQuotesFrom) {
$hideQuotesFrom.appendChild(
h('section', null,
h('label', {className: 'button'},
h('span', null, `@${user}`),
h('button', {
type: 'button',
onclick() {
optionsConfig.hideQuotesFrom = optionsConfig.hideQuotesFrom.filter(u => u != user)
storeConfigChanges({hideQuotesFrom: optionsConfig.hideQuotesFrom})
updateDisplay()
}
}, chrome.i18n.getMessage('unmuteButtonText'))
)
)
)
}
}
function updateMutedQuotesDisplay() {
if (!shouldDisplayMutedQuotes()) return
$mutedQuotesLabel.textContent = chrome.i18n.getMessage('mutedTweetsLabel', String(optionsConfig.mutedQuotes.length))
if (!$mutedQuotesDetails.open) return
while ($mutedQuotes.hasChildNodes()) $mutedQuotes.firstChild.remove()
optionsConfig.mutedQuotes.forEach(({user, time, text}, index) => {
$mutedQuotes.appendChild(
h('section', null,
h('label', {className: 'button mutedQuote'},
h('div', null,
user,
' ',
new Intl.DateTimeFormat([], {dateStyle: 'medium'}).format(new Date(time)),
text && h('p', {className: 'mb-0'}, text),
),
h('button', {
type: 'button',
onclick: () => {
optionsConfig.mutedQuotes = optionsConfig.mutedQuotes.filter((_, i) => i != index)
chrome.storage.local.set({mutedQuotes: optionsConfig.mutedQuotes})
updateDisplay()
},
}, chrome.i18n.getMessage('unmuteButtonText'))
)
)
)
})
}
function updateFormControls() {
Object.keys(optionsConfig)
.filter(prop => prop in $form.elements)
.forEach(prop => updateFormControl($form.elements[prop], optionsConfig[prop]))
}
function updateFormControl($control, value) {
if ($control instanceof RadioNodeList) {
// If a checkbox displays in multiple sections, update them all
$control.forEach(input => /** @type {HTMLInputElement} */ (input).checked = value)
}
else if ($control.type == 'checkbox') {
$control.checked = value
}
else {
$control.value = value
}
}
//#endregion
//#region Main
function main() {
chrome.storage.local.get((/** @type {Partial<import("./types").Config>} */ storedConfig) => {
// Update deprecated config values
// @ts-ignore
if (storedConfig.twitterBlueChecks == 'dim') {
storedConfig.twitterBlueChecks = 'replace'
}
optionsConfig = {...defaultConfig, ...storedConfig}
$body.classList.toggle('debug', optionsConfig.debug === true)
$experiments.open = Boolean(optionsConfig.customCss)
$exportConfig.addEventListener('click', exportConfig)
$form.addEventListener('change', onFormChanged)
$hideQuotesFromDetails.addEventListener('toggle', updateHideQuotesFromDisplay)
$mutedQuotesDetails.addEventListener('toggle', updateMutedQuotesDisplay)
$saveCustomCssButton.addEventListener('click', saveCustomCss)
chrome.storage.onChanged.addListener(onStorageChanged)
if (!optionsConfig.debug) {
let $version = document.querySelector('#version')
let $debugCountdown = document.querySelector('#debugCountdown')
let debugCountdown = 5
function onClick(e) {
if (e.target === $version || $version.contains(/** @type {Node} */ (e.target))) {
debugCountdown--
} else {
debugCountdown = 5
}
if (debugCountdown == 0) {
$body.classList.add('debug')
$debugCountdown.textContent = ''
$form.removeEventListener('click', onClick)
}
else if (debugCountdown <= 3) {
$debugCountdown.textContent = ` (${debugCountdown})`
}
}
$form.addEventListener('click', onClick)
}
applyConfig()
})
}
main()
//#endregion