control-panel-for-twitter/options.js
Jonny Buchanan b3076019c8 - Added checkSocialContext to TimelineOptions instead of hardcoding it for profile timelines
- Fixed Community Tweets being treated as Retweets
- Fixed handling timelines in Communities tabs
- Replaced hardcoding of the primary column selector

Options:
- Fixed options textarea styles
- Fixed background in macOS Safari options popup in dark mode
- Apply other settings changes after options input changes
2025-06-15 18:55:44 +10:00

565 lines
18 KiB
JavaScript
Raw Permalink 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.

import {defaultSettings} from './settings.js'
import {get, set} from './storage.js'
//#region Localisation
document.title = chrome.i18n.getMessage(`extensionName`)
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',
'customCssLabel',
'debugInfo',
'debugLabel',
'debugLogTimelineStatsLabel',
'debugOptionsLabel',
'defaultToFollowingLabel',
'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',
'hidePremiumRepliesLabel',
'hidePremiumUpsellsLabel',
'hideProfileHeaderMetricsLabel',
'hideProfileRetweetsLabel',
'hideQuoteTweetMetricsLabel',
'hideReplyMetricsLabel',
'hideRetweetMetricsLabel',
'hideSeeNewTweetsLabel',
'hideShareTweetButtonLabel',
'hideSidebarContentLabel',
'hideSpacesNavLabel',
'hideSubscriptionsLabel',
'hideSuggestedFollowsLabel',
'hideTimelineTweetBoxLabel',
'hideToggleNavigationLabel',
'hideTweetAnalyticsLinksLabel',
'hideUnavailableQuoteTweetsLabel',
'hideUnusedUiItemsOptionsLabel',
'hideVerifiedTabsLabel',
'hideViewsLabel',
'hideWhatsHappeningLabel',
'hideWhoToFollowEtcLabel',
'homeTimelineOptionsLabel',
'listRetweetsLabel',
'mutableQuoteTweetsLabel',
'navBaseFontSizeLabel',
'navDensityLabel',
'premiumBlueChecksLabel',
'premiumBlueChecksOption_replace',
'preventNextVideoAutoplayInfo',
'preventNextVideoAutoplayLabel',
'quoteTweetsLabel',
'redirectToTwitterLabel',
'reduceAlgorithmicContentOptionsLabel',
'reduceEngagementOptionsLabel',
'reducedInteractionModeInfo',
'reducedInteractionModeLabel',
'restoreLinkHeadlinesLabel',
'restoreOtherInteractionLinksLabel',
'restoreQuoteTweetsLinkLabel',
'restoreTweetSourceLabel',
'retweetsLabel',
'revertXBrandingLabel',
'showBookmarkButtonUnderFocusedTweetsLabel',
'showPremiumReplyBusinessLabel',
'showPremiumReplyFollowedByLabel',
'showPremiumReplyFollowersCountAmountLabel',
'showPremiumReplyFollowingLabel',
'showPremiumReplyGovernmentLabel',
'showRelevantPeopleLabel',
'sidebarLabel',
'sortRepliesLabel',
'tweakNewLayoutInfo',
'tweakNewLayoutLabel',
'tweakQuoteTweetsPageLabel',
'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)
}
//#endregion
/**
* Internal options which map directly to a form element.
* @type {import('./types').StoredConfigKey[]}
*/
const INTERNAL_CONFIG_FORM_KEYS = ['enabled', 'debug', 'debugLogTimelineStats']
/** @type {Set<string>} */
const INTERNAL_CONFIG_FORM_KEYSET = new Set(INTERNAL_CONFIG_FORM_KEYS)
/** @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").StoredConfig} */
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',
}
//#endregion
//#region Config & variables
/**
* @type {import("./types").StoredConfig}
*/
let config
/**
* 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 $showPremiumReplyFollowersCountLabel = /** @type {HTMLElement} */ (document.querySelector('#showPremiumReplyFollowersCountLabel'))
//#endregion
//#region Utility functions
function exportConfig() {
let $a = document.createElement('a')
$a.download = 'control-panel-for-twitter-v4.12.1.config.txt'
$a.href = URL.createObjectURL(new Blob([
JSON.stringify(config, 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 = config.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',
'hideProfileHeaderMetrics',
]
}))
updateFormControls()
updateCheckboxGroups()
updateDisplay()
}
function onFormChanged(/** @type {Event} */ e) {
if (e.target instanceof HTMLTextAreaElement) return
/** @type {import("./types").StoredConfig} */
let changedConfig = Object.create(null)
/** @type {Partial<import("./types").UserSettings>} */
let changedSettings = Object.create(null)
// Handle input
let $el = /** @type {HTMLInputElement} */ (e.target)
if ($el.type == 'checkbox') {
// All internal config is currently checkbox toggles
if (INTERNAL_CONFIG_FORM_KEYSET.has($el.name)) {
config[$el.name] = changedConfig[$el.name] = $el.checked
}
// Checkbox group toggle
else if (checkboxGroups.has($el.name)) {
checkboxGroups.get($el.name).forEach(checkboxName => {
config.settings[checkboxName] = changedSettings[checkboxName] = $el.checked
updateFormControl($form.elements[checkboxName], $el.checked)
})
$el.indeterminate = false
}
// Individual checkbox toggle
else {
config.settings[$el.name] = changedSettings[$el.name] = $el.checked
updateCheckboxGroups()
}
} else {
config.settings[$el.name] = changedSettings[$el.name] = $el.value
}
// Apply other settings changes based on input
// Don't try to redirect the Home timeline to Notifications if both are disabled
if (changedSettings.hideNotifications &&
config.settings.disabledHomeTimelineRedirect == 'notifications') {
let key = 'disabledHomeTimelineRedirect'
$form.elements[key].value = config.settings[key] = changedSettings[key] = 'messages'
}
updateDisplay()
if (Object.keys(changedSettings).length > 0) {
changedConfig.settings = changedSettings
}
storeConfigChanges(changedConfig)
}
/**
* Listen for storage changes while the options page is open and update its
* controls to reflect them. We only store settings the user has interacted
* with.
* @param {{[key: string]: chrome.storage.StorageChange}} storageChanges
*/
function onStorageChanged(storageChanges) {
let changes = Object.fromEntries(
Object.entries(storageChanges).map(([key, {newValue}]) => [key, newValue])
)
let {settings: settingsChanges, ...configChanges} = changes
Object.assign(config, configChanges)
Object.assign(config.settings, settingsChanges)
applyConfig()
}
function saveCustomCss() {
let customCss = $form.elements['customCss'].value
if (config.settings.customCss == customCss) return
config.settings.customCss = customCss
storeConfigChanges({settings: {customCss}})
}
function shouldDisplayHideQuotesFrom() {
return config.settings.mutableQuoteTweets && config.settings.hideQuotesFrom.length > 0
}
function shouldDisplayMutedQuotes() {
return config.settings.mutableQuoteTweets && config.settings.mutedQuotes.length > 0
}
/**
* @param {Partial<import("./types").StoredConfig>} changes
*/
async function storeConfigChanges(changes) {
/** @type {Partial<import("./types").StoredConfig>} */
let changesToStore = {}
for (let key of Object.keys(defaultConfig)) {
if (Object.hasOwn(changes, key)) {
changesToStore[key] = changes[key]
}
}
try {
if (changes.settings) {
let {settings: storedSettings} = await get({settings: {}})
changesToStore.settings = {...storedSettings, ...changes.settings}
}
} catch(e) {
console.error('[options] error merging settings change', e)
}
try {
await set(changesToStore)
} catch(e) {
console.error('[options] error storing config change', e)
}
}
function updateCheckboxGroups() {
for (let [group, checkboxNames] of checkboxGroups.entries()) {
let checkedCount = checkboxNames.filter(name => config.settings[name]).length
$form.elements[group].checked = checkedCount == checkboxNames.length
$form.elements[group].indeterminate = checkedCount > 0 && checkedCount < checkboxNames.length;
}
}
function updateDisplay() {
$body.classList.toggle('chronological', config.settings.defaultToFollowing)
$body.classList.toggle('debugging', config.debug)
$body.classList.toggle('disabled', !config.enabled)
$body.classList.toggle('disabledHomeTimeline', config.settings.disableHomeTimeline)
$body.classList.toggle('fullWidthContent', config.settings.fullWidthContent)
$body.classList.toggle('hidingBookmarkButton', config.settings.hideBookmarkButton)
$body.classList.toggle('hidingExploreNav', config.settings.hideExploreNav)
$body.classList.toggle('hidingMetrics', config.settings.hideMetrics)
$body.classList.toggle('hidingNotifications', config.settings.hideNotifications == 'hide')
$body.classList.toggle('hidingQuotesFrom', shouldDisplayHideQuotesFrom())
$body.classList.toggle('hidingSuggestedFollows', config.settings.hideSidebarContent || config.settings.hideSuggestedFollows)
$body.classList.toggle('hidingTwitterBlueReplies', config.settings.hidePremiumReplies)
$body.classList.toggle('mutingQuotes', shouldDisplayMutedQuotes())
$body.classList.toggle('showingBlueReplyFollowersCount', config.settings.showPremiumReplyFollowersCount)
$body.classList.toggle('showingSidebarContent', !config.settings.hideSidebarContent)
$body.classList.toggle('tweakingNewLayout', config.settings.tweakNewLayout)
$body.classList.toggle('uninvertedFollowButtons', config.settings.uninvertFollowButtons)
$showPremiumReplyFollowersCountLabel.textContent = chrome.i18n.getMessage(
'showPremiumReplyFollowersCountLabel',
formatFollowerCount(Number(config.settings.showPremiumReplyFollowersCountAmount))
)
updateHideQuotesFromDisplay()
updateMutedQuotesDisplay()
}
function updateHideQuotesFromDisplay() {
if (!shouldDisplayHideQuotesFrom()) return
$hideQuotesFromLabel.textContent =
chrome.i18n.getMessage('hideQuotesFromLabel', String(config.settings.hideQuotesFrom.length))
if (!$hideQuotesFromDetails.open) return
while ($hideQuotesFrom.hasChildNodes()) $hideQuotesFrom.firstChild.remove()
for (let user of config.settings.hideQuotesFrom) {
$hideQuotesFrom.appendChild(
h('section', null,
h('label', {className: 'button'},
h('span', null, `@${user}`),
h('button', {
type: 'button',
onclick() {
config.settings.hideQuotesFrom = config.settings.hideQuotesFrom.filter(u => u != user)
updateDisplay()
storeConfigChanges({settings: {hideQuotesFrom: config.settings.hideQuotesFrom}})
}
}, chrome.i18n.getMessage('unmuteButtonText'))
)
)
)
}
}
function updateMutedQuotesDisplay() {
if (!shouldDisplayMutedQuotes()) return
$mutedQuotesLabel.textContent = chrome.i18n.getMessage('mutedTweetsLabel', String(config.settings.mutedQuotes.length))
if (!$mutedQuotesDetails.open) return
while ($mutedQuotes.hasChildNodes()) $mutedQuotes.firstChild.remove()
config.settings.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: () => {
config.settings.mutedQuotes = config.settings.mutedQuotes.filter((_, i) => i != index)
set({mutedQuotes: config.settings.mutedQuotes})
updateDisplay()
},
}, chrome.i18n.getMessage('unmuteButtonText'))
)
)
)
})
}
function updateFormControls() {
for (let key of INTERNAL_CONFIG_FORM_KEYSET) {
updateFormControl($form.elements[key], config[key])
}
for (let key of Object.keys(config.settings)) {
if (key in $form.elements) {
updateFormControl($form.elements[key], config.settings[key])
}
}
}
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
async function main() {
/** @type {Partial<import("./types").StoredConfig>} */
let {settings: storedSettings = {}, ...storedConfig} = await get()
config = {
...defaultConfig,
...storedConfig,
settings: {...defaultSettings, ...storedSettings}
}
$body.classList.toggle('debug', config.debug === true)
$experiments.open = Boolean(config.settings.tweakNewLayout || config.settings.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 (!config.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()
// Open a port for the background page to detect when the options page is closed
chrome.runtime.connect({name: 'options'})
//#endregion