control-panel-for-twitter/options.js
2025-02-16 00:44:29 +10:00

596 lines
19 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 [
'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',
'debugInfo',
'debugLabel',
'debugOptionsLabel',
'defaultToLatestSearchLabel',
'disableHomeTimelineInfo',
'disableHomeTimelineLabel',
'disableTweetTextFormattingLabel',
'disabledHomeTimelineRedirectLabel',
'disabledHomeTimelineRedirectOption_messages',
'dontUseChirpFontLabel',
'dropdownMenuFontWeightLabel',
'experimentalNote',
'experimentsOptionsLabel',
'exportConfigLabel',
'fastBlockLabel',
'followButtonStyleLabel',
'followButtonStyleOption_monochrome',
'followButtonStyleOption_themed',
'fullWidthContentInfo',
'fullWidthContentLabel',
'fullWidthMediaLabel',
'hideAccountSwitcherLabel',
'hideAdsNavLabel',
'hideAllMetricsLabel',
'hideBlueReplyFollowedByLabel',
'hideBlueReplyFollowingLabel',
'hideBookmarkButtonLabel',
'hideBookmarkMetricsLabel',
'hideExploreNavLabel',
'hideExploreNavWithSidebarLabel',
'hideExplorePageContentsLabel',
'hideFollowingMetricsLabel',
'hideForYouTimelineLabel',
'hideGrokLabel',
'hideInlinePrompts',
'hideJobsLabel',
'hideLikeMetricsLabel',
'hideMessagesBottomNavItemLabel',
'hideMessagesDrawerLabel',
'hideMetricsLabel',
'hideMonetizationNavLabel',
'hideMoreSlideOutMenuItemsOptionsLabel_desktop',
'hideMoreSlideOutMenuItemsOptionsLabel_mobile',
'hideMoreTweetsLabel',
'hideProfileRetweetsLabel',
'hideQuoteTweetMetricsLabel',
'hideReplyMetricsLabel',
'hideRetweetMetricsLabel',
'hideSeeNewTweetsLabel',
'hideShareTweetButtonLabel',
'hideSidebarContentLabel',
'hideSpacesNavLabel',
'hideSubscriptionsLabel',
'hideTimelineTweetBoxLabel',
'hideToggleNavigationLabel',
'hideTotalTweetsMetricsLabel',
'hideTweetAnalyticsLinksLabel',
'hideTwitterBlueRepliesLabel',
'hideTwitterBlueUpsellsLabel',
'hideUnavailableQuoteTweetsLabel',
'hideUnusedUiItemsOptionsLabel',
'hideVerifiedNotificationsTabLabel',
'hideViewsLabel',
'hideWhoToFollowEtcLabel',
'homeTimelineOptionsLabel',
'listRetweetsLabel',
'mutableQuoteTweetsLabel',
'navBaseFontSizeLabel',
'navDensityLabel',
'quoteTweetsLabel',
'redirectToTwitterLabel',
'reduceAlgorithmicContentOptionsLabel',
'reduceEngagementOptionsLabel',
'reducedInteractionModeInfo',
'reducedInteractionModeLabel',
'replaceLogoLabel',
'restoreLinkHeadlinesLabel',
'restoreOtherInteractionLinksLabel',
'restoreQuoteTweetsLinkLabel',
'retweetsLabel',
'showBlueReplyFollowersCountAmountLabel',
'showBlueReplyVerifiedAccountsLabel',
'showBookmarkButtonUnderFocusedTweetsLabel',
'showRelevantPeopleLabel',
'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',
]) {
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 = {
debug: 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,
hideBlueReplyFollowedBy: false,
hideBlueReplyFollowing: false,
hideBookmarkButton: false,
hideBookmarkMetrics: true,
hideBookmarksNav: false,
hideCommunitiesNav: false,
hideExplorePageContents: true,
hideFollowingMetrics: true,
hideForYouTimeline: true,
hideGrokNav: true,
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,
hideTimelineTweetBox: false,
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,
retweets: 'separate',
showBlueReplyFollowersCountAmount: '1000000',
showBlueReplyFollowersCount: false,
showBlueReplyVerifiedAccounts: false,
showBookmarkButtonUnderFocusedTweets: true,
sortReplies: 'relevant',
tweakNewLayout: false,
tweakQuoteTweetsPage: true,
twitterBlueChecks: 'replace',
uninvertFollowButtons: true,
unblurSensitiveContent: false,
// Experiments
// none currently
// Desktop only
fullWidthContent: false,
fullWidthMedia: true,
hideAccountSwitcher: false,
hideExploreNav: true,
hideExploreNavWithSidebar: true,
hideMessagesDrawer: true,
hideProNav: true,
hideSidebarContent: true,
hideSpacesNav: false,
hideToggleNavigation: false,
navBaseFontSize: true,
navDensity: 'default',
showRelevantPeople: false,
// Mobile only
hideMessagesBottomNavItem: false,
}
//#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 $showBlueReplyFollowersCountLabel = /** @type {HTMLElement} */ (document.querySelector('#showBlueReplyFollowersCountLabel'))
//#endregion
//#region Utility functions
function exportConfig() {
let $a = document.createElement('a')
$a.download = 'control-panel-for-twitter-v4.7.1.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({
hideUnusedUiItems: [
'hideBookmarkButton',
'hideGrokNav',
'hideShareTweetButton',
'hideTweetAnalyticsLinks',
desktop && 'hideAccountSwitcher',
desktop && 'hideBookmarksNav',
desktop && 'hideCommunitiesNav',
desktop && 'hideExploreNav',
desktop && 'hideListsNav',
desktop && 'hideMessagesDrawer',
desktop && 'hideTimelineTweetBox',
mobile && 'hideMessagesBottomNavItem',
].filter(Boolean),
hideMoreSlideOutMenuItems: [
'hideAdsNav',
'hideMonetizationNav',
desktop && 'hideProNav',
mobile && 'hideBookmarksNav',
mobile && 'hideCommunitiesNav',
mobile && 'hideListsNav',
].filter(Boolean),
hideAllMetrics: [
'hideBookmarkMetrics',
'hideFollowingMetrics',
'hideLikeMetrics',
'hideReplyMetrics',
'hideRetweetMetrics',
'hideQuoteTweetMetrics',
'hideTotalTweetsMetrics',
]
}))
updateFormControls()
updateCheckboxGroups()
updateDisplay()
}
/**
* @param {Event} e
*/
function onFormChanged(e) {
/** @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 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('chronological', optionsConfig.alwaysUseLatestTweets)
$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('hidingSidebarContent', optionsConfig.hideSidebarContent)
$body.classList.toggle('hidingTwitterBlueReplies', optionsConfig.hideTwitterBlueReplies)
$body.classList.toggle('mutingQuotes', shouldDisplayMutedQuotes())
$body.classList.toggle('showingBlueReplyFollowersCount', optionsConfig.showBlueReplyFollowersCount)
$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 = (...)
$exportConfig.addEventListener('click', exportConfig)
$form.addEventListener('change', onFormChanged)
$hideQuotesFromDetails.addEventListener('toggle', updateHideQuotesFromDisplay)
$mutedQuotesDetails.addEventListener('toggle', updateMutedQuotesDisplay)
chrome.storage.onChanged.addListener(onStorageChanged)
if (!optionsConfig.debug) {
let $showDebugOptions = document.querySelector('#showDebugOptions')
let $debugCountdown = document.querySelector('#debugCountdown')
let debugCountdown = 5
function onClick(e) {
if (e.target === $showDebugOptions || $showDebugOptions.contains(/** @type {Node} */ (e.target))) {
debugCountdown--
} else {
debugCountdown = 5
}
if (debugCountdown == 0) {
$body.classList.add('debug')
$debugCountdown.textContent = ''
$form.removeEventListener('click', onClick)
$showDebugOptions.classList.remove('clickable')
}
else if (debugCountdown <= 3) {
$debugCountdown.textContent = chrome.i18n.getMessage('debugCountdownLabel', String(debugCountdown))
}
}
$form.addEventListener('click', onClick)
$showDebugOptions.classList.add('clickable')
}
applyConfig()
})
}
main()
//#endregion