/**
* Copyright (C) 2021-2023 Thomas Weber
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 3 as
* published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import Search from './search';
import importedAddons from '../generated/addon-manifests';
import messagesByLocale from '../generated/l10n-settings-entries';
import settingsTranslationsEnglish from './en.json';
import settingsTranslationsOther from './translations.json';
import upstreamMeta from '../generated/upstream-meta.json';
import {detectLocale} from '../../lib/detect-locale';
import SettingsStore from '../settings-store-singleton';
import Channels from '../channels';
import extensionImage from './icons/extension.svg';
import brushImage from './icons/brush.svg';
import undoImage from './icons/undo.svg';
import expandImageBlack from './icons/expand.svg';
import infoImage from './icons/info.svg';
import TWFancyCheckbox from '../../components/tw-fancy-checkbox/checkbox.jsx';
import styles from './settings.css';
import {detectTheme} from '../../lib/themes/themePersistance.js';
import {applyGuiColors} from '../../lib/themes/guiHelpers.js';
import {APP_NAME, FEEDBACK_URL} from '../../lib/brand.js';
import '../../lib/normalize.css';
/* eslint-disable no-alert */
/* eslint-disable no-console */
/* eslint-disable react/no-multi-comp */
/* eslint-disable react/jsx-no-bind */
const locale = detectLocale(Object.keys(messagesByLocale));
document.documentElement.lang = locale;
const addonTranslations = messagesByLocale[locale] ? messagesByLocale[locale]() : {};
const settingsTranslations = settingsTranslationsEnglish;
if (locale !== 'en') {
const messages = settingsTranslationsOther[locale] || settingsTranslationsOther[locale.split('-')[0]];
if (messages) {
Object.assign(settingsTranslations, messages);
}
}
document.title = `${settingsTranslations.title} - ${APP_NAME}`;
const theme = detectTheme();
applyGuiColors(theme);
let _throttleTimeout;
const postThrottledSettingsChange = store => {
if (_throttleTimeout) {
clearTimeout(_throttleTimeout);
}
_throttleTimeout = setTimeout(() => {
Channels.changeChannel.postMessage({
version: upstreamMeta.commit,
store
});
}, 100);
};
const filterAddonsBySupport = () => {
const supported = {};
const unsupported = {};
for (const [id, manifest] of Object.entries(importedAddons)) {
if (manifest.unsupported) {
unsupported[id] = manifest;
} else {
supported[id] = manifest;
}
}
return {
supported,
unsupported
};
};
const {supported: supportedAddons, unsupported: unsupportedAddons} = filterAddonsBySupport();
const groupAddons = () => {
const groups = {
new: {
label: settingsTranslations.groupNew,
open: true,
addons: []
},
others: {
label: settingsTranslations.groupOthers,
open: true,
addons: []
},
danger: {
label: settingsTranslations.groupDanger,
open: false,
addons: []
}
};
const manifests = Object.values(supportedAddons);
for (let index = 0; index < manifests.length; index++) {
const manifest = manifests[index];
if (manifest.tags.includes('new')) {
groups.new.addons.push(index);
} else if (manifest.tags.includes('danger') || manifest.noCompiler) {
groups.danger.addons.push(index);
} else {
groups.others.addons.push(index);
}
}
return groups;
};
const getAllTags = () => {
const tags = new Set();
for (const manifest of Object.values(supportedAddons)) {
for (const tag of manifest.tags) {
tags.add(tag);
}
}
return Array.from(tags).sort();
};
const allTags = getAllTags();
const getInitialSearch = () => {
const hash = location.hash.substring(1);
// If the query is an addon ID, it's a better user experience to show the name of the addon
// in the search bar instead of a ID they won't understand.
if (Object.prototype.hasOwnProperty.call(importedAddons, hash)) {
const manifest = importedAddons[hash];
return addonTranslations[`${hash}/@name`] || manifest.name;
}
return hash;
};
const clearHash = () => {
// Don't want to insert unnecssary history entry
// location.hash = ''; leaves a # in the URL
if (location.hash !== '') {
history.replaceState(null, null, `${location.pathname}${location.search}`);
}
};
const CreditList = ({credits}) => (
credits.map((author, index) => {
const isLast = index === credits.length - 1;
return (
{author.link ? (
{author.name}
) : (
{author.name}
)}
{isLast ? null : ', '}
);
})
);
CreditList.propTypes = {
credits: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string,
link: PropTypes.string
}))
};
const TagFilter = ({tags, selectedTags, onTagToggle, onClearAll}) => {
if (tags.length === 0) return null;
return (
{settingsTranslations.filterByTags || 'Filter by tags:'}
{tags.map(tag => (
onTagToggle(tag)}
aria-pressed={selectedTags.has(tag)}
>
{settingsTranslations[`tags.${tag}`] || tag}
))}
{selectedTags.size > 0 && (
{'×'}
)}
);
};
TagFilter.propTypes = {
tags: PropTypes.arrayOf(PropTypes.string).isRequired,
selectedTags: PropTypes.instanceOf(Set).isRequired,
onTagToggle: PropTypes.func.isRequired,
onClearAll: PropTypes.func.isRequired
};
const Switch = ({onChange, value, ...props}) => (
onChange(!value)}
{...props}
/>
);
Switch.propTypes = {
onChange: PropTypes.func,
value: PropTypes.bool
};
const Select = ({
onChange,
value,
values
}) => (
{values.map(potentialValue => {
const id = potentialValue.id;
const selected = id === value;
return (
onChange(id)}
className={classNames(styles.selectOption, {[styles.selected]: selected})}
>
{potentialValue.name}
);
})}
);
Select.propTypes = {
onChange: PropTypes.func,
value: PropTypes.string,
values: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
name: PropTypes.string
}))
};
const Tags = ({manifest}) => (
{manifest.tags.includes('recommended') && (
{settingsTranslations.tagRecommended}
)}
{manifest.tags.includes('theme') && (
{settingsTranslations.tagTheme}
)}
{manifest.tags.includes('beta') && (
{settingsTranslations.tagBeta}
)}
{manifest.tags.includes('new') && (
{settingsTranslations.tagNew}
)}
{manifest.tags.includes('danger') && (
{settingsTranslations.tagDanger}
)}
);
Tags.propTypes = {
manifest: PropTypes.shape({
tags: PropTypes.arrayOf(PropTypes.string.isRequired).isRequired
}).isRequired
};
class TextInput extends React.Component {
constructor (props) {
super(props);
this.handleKeyPress = this.handleKeyPress.bind(this);
this.handleFocus = this.handleFocus.bind(this);
this.handleFlush = this.handleFlush.bind(this);
this.handleChange = this.handleChange.bind(this);
this.state = {
value: null,
focused: false
};
}
handleKeyPress (e) {
if (e.key === 'Enter') {
this.handleFlush(e);
e.target.blur();
}
}
handleFocus () {
this.setState({
focused: true
});
}
handleFlush (e) {
this.setState({
focused: false
});
if (this.state.value === null) {
return;
}
if (this.props.type === 'number') {
let value = +this.state.value;
const min = e.target.min;
const max = e.target.max;
const step = e.target.step;
if (min !== '') value = Math.max(min, value);
if (max !== '') value = Math.min(max, value);
if (step === '1') value = Math.round(value);
this.props.onChange(value);
} else {
this.props.onChange(this.state.value);
}
this.setState({value: null});
}
handleChange (e) {
e.persist();
this.setState({value: e.target.value}, () => {
// A change event can be fired when not focused by using the browser's number spinners
if (!this.state.focused) {
this.handleFlush(e);
}
});
}
render () {
return (
);
}
}
TextInput.propTypes = {
onChange: PropTypes.func.isRequired,
type: PropTypes.string,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
};
const ColorInput = props => (
);
ColorInput.propTypes = {
id: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
value: PropTypes.string.isRequired
};
const ResetButton = ({
addonId,
settingId,
forTextInput
}) => (
SettingsStore.setAddonSetting(addonId, settingId, null)}
title={settingsTranslations.reset}
data-for-text-input={forTextInput}
>
);
ResetButton.propTypes = {
addonId: PropTypes.string,
settingId: PropTypes.string,
forTextInput: PropTypes.bool
};
const Setting = ({
addonId,
setting,
value
}) => {
if (!SettingsStore.evaluateCondition(addonId, setting.if)) {
return null;
}
const settingId = setting.id;
const settingName = addonTranslations[`${addonId}/@settings-name-${settingId}`] || setting.name;
const uniqueId = `setting/${addonId}/${settingId}`;
const label = (
{settingName}
);
return (
{setting.type === 'boolean' && (
{label}
SettingsStore.setAddonSetting(addonId, settingId, e.target.checked)}
/>
)}
{(setting.type === 'integer' || setting.type === 'positive_integer') && (
{label}
SettingsStore.setAddonSetting(addonId, settingId, newValue)}
/>
)}
{(setting.type === 'string' || setting.type === 'untranslated') && (
{label}
SettingsStore.setAddonSetting(addonId, settingId, newValue)}
/>
)}
{setting.type === 'color' && (
{label}
SettingsStore.setAddonSetting(addonId, settingId, e.target.value)}
/>
)}
{setting.type === 'select' && (
{label}
({
id,
name: addonTranslations[`${addonId}/@settings-select-${settingId}-${id}`] || name
}))}
onChange={v => SettingsStore.setAddonSetting(addonId, settingId, v)}
setting={setting}
/>
)}
);
};
Setting.propTypes = {
addonId: PropTypes.string,
setting: PropTypes.shape({
type: PropTypes.string,
id: PropTypes.string,
name: PropTypes.string,
min: PropTypes.number,
max: PropTypes.number,
default: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool]),
potentialValues: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
name: PropTypes.string
})),
if: PropTypes.shape({
addonEnabled: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]),
// eslint-disable-next-line react/forbid-prop-types
settings: PropTypes.object
})
}),
value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool, PropTypes.number])
};
const Notice = ({
type,
text
}) => (
{text}
);
Notice.propTypes = {
type: PropTypes.string,
text: PropTypes.string
};
const Presets = ({
addonId,
presets
}) => (
{settingsTranslations.presets}
{presets.map(preset => {
const presetId = preset.id;
const name = addonTranslations[`${addonId}/@preset-name-${presetId}`] || preset.name;
const description = addonTranslations[`${addonId}/@preset-description-${presetId}`] || preset.description;
return (
SettingsStore.applyAddonPreset(addonId, presetId)}
>
{name}
);
})}
);
Presets.propTypes = {
addonId: PropTypes.string,
presets: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string,
id: PropTypes.string,
description: PropTypes.string,
values: PropTypes.shape({})
}))
};
const Addon = ({
id,
settings,
manifest,
extended
}) => (
{
if (
!value ||
!manifest.tags.includes('danger') ||
confirm(settingsTranslations.enableDangerous)
) {
SettingsStore.setAddonEnabled(id, value);
}
}}
/>
{manifest.tags.includes('theme') ? (
) : (
)}
{addonTranslations[`${id}/@name`] || manifest.name}
{extended && (
{`(${id})`}
)}
{!settings.enabled && (
{addonTranslations[`${id}/@description`] || manifest.description}
)}
{settings.enabled && manifest.settings && (
SettingsStore.resetAddon(id)}
title={settingsTranslations.reset}
>
)}
{settings.enabled && (
{addonTranslations[`${id}/@description`] || manifest.description}
{manifest.credits && (
{settingsTranslations.credits}
)}
{manifest.info && (
manifest.info.map(info => (
))
)}
{manifest.noCompiler && (
)}
{manifest.settings && (
{manifest.settings.map(setting => (
))}
{manifest.presets && (
)}
)}
)}
);
Addon.propTypes = {
id: PropTypes.string,
settings: PropTypes.shape({
enabled: PropTypes.bool,
dirty: PropTypes.bool
}),
manifest: PropTypes.shape({
name: PropTypes.string,
description: PropTypes.string,
credits: PropTypes.arrayOf(PropTypes.shape({})),
info: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string
})),
settings: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string
})),
presets: PropTypes.arrayOf(PropTypes.shape({})),
tags: PropTypes.arrayOf(PropTypes.string),
noCompiler: PropTypes.bool
}),
extended: PropTypes.bool
};
const Dirty = props => (
{settingsTranslations.dirty}
{props.onReloadNow && (
{settingsTranslations.dirtyButton}
)}
);
Dirty.propTypes = {
onReloadNow: PropTypes.func
};
const UnsupportedAddons = ({addons: addonList}) => (
{settingsTranslations.unsupported}
{addonList.map(({id, manifest}, index) => (
{addonTranslations[`${id}/@name`] || manifest.name}
{index !== addonList.length - 1 && (
', '
)}
))}
);
UnsupportedAddons.propTypes = {
addons: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
manifest: PropTypes.shape({
name: PropTypes.string
})
}))
};
const InternalAddonList = ({addons, extended}) => (
addons.map(({id, manifest, state}) => (
))
);
class AddonGroup extends React.Component {
constructor (props) {
super(props);
this.state = {
open: props.open
};
}
render () {
if (this.props.addons.length === 0) {
return null;
}
return (
{
this.setState({
open: !this.state.open
});
}}
>
{this.props.label.replace('{number}', this.props.addons.length)}
{this.state.open && (
)}
);
}
}
AddonGroup.propTypes = {
label: PropTypes.string,
open: PropTypes.bool,
addons: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string.isRequired,
state: PropTypes.shape({}).isRequired,
manifest: PropTypes.shape({}).isRequired
})).isRequired,
extended: PropTypes.bool.isRequired
};
const addonToSearchItem = ({id, manifest}) => {
const texts = new Set();
const addText = (score, text) => {
if (text) {
texts.add({
score,
text
});
}
};
addText(1, id);
addText(1, manifest.name);
addText(1, addonTranslations[`${id}/@name`]);
addText(0.5, manifest.description);
addText(0.5, addonTranslations[`${id}/@description`]);
if (manifest.settings) {
for (const setting of manifest.settings) {
addText(0.25, setting.name);
addText(0.25, addonTranslations[`${id}/@settings-name-${setting.id}`]);
}
}
if (manifest.presets) {
for (const preset of manifest.presets) {
addText(0.1, preset.name);
addText(0.1, addonTranslations[`${id}/@preset-name-${preset.id}`]);
addText(0.1, preset.description);
addText(0.1, addonTranslations[`${id}/@preset-description-${preset.id}`]);
}
}
for (const tag of manifest.tags) {
const key = `tags.${tag}`;
if (settingsTranslations[key]) {
addText(0.25, settingsTranslations[key]);
}
}
if (manifest.info) {
for (const info of manifest.info) {
addText(0.25, info.text);
addText(0.25, addonTranslations[`${id}/@info-${info.id}`]);
}
}
return texts;
};
class AddonList extends React.Component {
constructor (props) {
super(props);
this.search = new Search(this.props.addons.map(addonToSearchItem));
this.groups = [];
}
filterAddonsByTags (addons) {
if (this.props.selectedTags.size === 0) {
return addons;
}
return addons.filter(addon =>
Array.from(this.props.selectedTags).some(tag =>
addon.manifest.tags.includes(tag)
)
);
}
render () {
let filteredAddons = this.props.addons;
// Apply tag filtering first
filteredAddons = this.filterAddonsByTags(filteredAddons);
if (this.props.search) {
// Rebuild search index with filtered addons
const search = new Search(filteredAddons.map(addonToSearchItem));
const addons = search.search(this.props.search)
.slice(0, 20)
.map(({index}) => filteredAddons[index]);
if (addons.length === 0) {
return (
{settingsTranslations.noResults}
);
}
return (
);
}
// Group filtered addons
const groupedFilteredAddons = {
new: {
label: settingsTranslations.groupNew,
open: true,
addons: []
},
others: {
label: settingsTranslations.groupOthers,
open: true,
addons: []
},
danger: {
label: settingsTranslations.groupDanger,
open: false,
addons: []
}
};
for (const addon of filteredAddons) {
if (addon.manifest.tags.includes('new')) {
groupedFilteredAddons.new.addons.push(addon);
} else if (addon.manifest.tags.includes('danger') || addon.manifest.noCompiler) {
groupedFilteredAddons.danger.addons.push(addon);
} else {
groupedFilteredAddons.others.addons.push(addon);
}
}
return (
{Object.entries(groupedFilteredAddons).map(([id, {label, addons, open}]) => (
addons.length > 0 && (
)
))}
);
}
}
AddonList.propTypes = {
addons: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string.isRequired,
state: PropTypes.shape({}).isRequired,
manifest: PropTypes.shape({}).isRequired
})).isRequired,
search: PropTypes.string.isRequired,
selectedTags: PropTypes.instanceOf(Set).isRequired,
extended: PropTypes.bool.isRequired
};
class AddonSettingsComponent extends React.Component {
constructor (props) {
super(props);
this.handleSettingStoreChanged = this.handleSettingStoreChanged.bind(this);
this.handleReloadNow = this.handleReloadNow.bind(this);
this.handleResetAll = this.handleResetAll.bind(this);
this.handleExport = this.handleExport.bind(this);
this.handleImport = this.handleImport.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleSearch = this.handleSearch.bind(this);
this.handleClickSearchButton = this.handleClickSearchButton.bind(this);
this.handleClickVersion = this.handleClickVersion.bind(this);
this.searchRef = this.searchRef.bind(this);
this.handleTagFilter = this.handleTagFilter.bind(this);
this.handleClearAll = this.handleClearAll.bind(this);
this.searchBar = null;
this.state = {
loading: false,
dirty: false,
search: getInitialSearch(),
extended: false,
selectedTags: new Set(),
...this.readFullAddonState()
};
if (Channels.changeChannel) {
Channels.changeChannel.addEventListener('message', () => {
SettingsStore.readLocalStorage();
this.setState(this.readFullAddonState());
});
}
}
componentDidMount () {
SettingsStore.addEventListener('setting-changed', this.handleSettingStoreChanged);
document.body.addEventListener('keydown', this.handleKeyDown);
}
componentDidUpdate (prevProps, prevState) {
if (this.state.search !== prevState.search) {
clearHash();
}
}
componentWillUnmount () {
SettingsStore.removeEventListener('setting-changed', this.handleSettingStoreChanged);
document.body.removeEventListener('keydown', this.handleKeyDown);
}
readFullAddonState () {
const result = {};
for (const [id, manifest] of Object.entries(supportedAddons)) {
const enabled = SettingsStore.getAddonEnabled(id);
const addonState = {
enabled: enabled,
dirty: false
};
if (manifest.settings) {
for (const setting of manifest.settings) {
addonState[setting.id] = SettingsStore.getAddonSetting(id, setting.id);
}
}
result[id] = addonState;
}
return result;
}
handleSettingStoreChanged (e) {
const {addonId, settingId, value} = e.detail;
// If channels are unavailable, every change requires reload.
const reloadRequired = e.detail.reloadRequired || !Channels.changeChannel;
this.setState(state => {
const newState = {
[addonId]: {
...state[addonId],
[settingId]: value,
dirty: true
}
};
if (reloadRequired) {
newState.dirty = true;
}
return newState;
});
if (!reloadRequired) {
postThrottledSettingsChange(SettingsStore.store);
}
}
handleReloadNow () {
// Value posted does not matter
Channels.reloadChannel.postMessage(0);
this.setState({
dirty: false
});
for (const addonId of Object.keys(supportedAddons)) {
if (this.state[addonId].dirty) {
this.setState(state => ({
[addonId]: {
...state[addonId],
dirty: false
}
}));
}
}
}
handleResetAll () {
if (confirm(settingsTranslations.confirmResetAll)) {
SettingsStore.resetAllAddons();
this.setState({
search: ''
});
}
}
handleExport () {
const exportedData = SettingsStore.export({
theme
});
this.props.onExportSettings(exportedData);
}
handleImport () {
const fileSelector = document.createElement('input');
fileSelector.type = 'file';
fileSelector.accept = '.json';
document.body.appendChild(fileSelector);
fileSelector.click();
document.body.removeChild(fileSelector);
fileSelector.addEventListener('change', async () => {
const file = fileSelector.files[0];
if (!file) {
return;
}
try {
const text = await file.text();
const data = JSON.parse(text);
SettingsStore.import(data);
this.setState({
search: ''
});
} catch (e) {
console.error(e);
alert(e);
}
});
}
handleSearch (e) {
const value = e.target.value;
this.setState({
search: value
});
}
handleClickSearchButton () {
this.setState({
search: ''
});
this.searchBar.focus();
}
handleClickVersion () {
this.setState({
extended: !this.state.extended
});
}
searchRef (searchBar) {
this.searchBar = searchBar;
// Only focus search bar if we have no initial search
if (searchBar && this.state.search === '') {
searchBar.focus();
}
}
handleKeyDown (e) {
const key = e.key;
if (key.length === 1 && key !== ' ' && e.target === document.body && !(e.ctrlKey || e.metaKey || e.altKey)) {
this.searchBar.focus();
}
// Only preventDefault() if the search bar isn't already focused so
// that we don't break the browser's builtin ctrl+f
if (key === 'f' && (e.ctrlKey || e.metaKey) && document.activeElement !== this.searchBar) {
this.searchBar.focus();
e.preventDefault();
}
}
handleTagFilter (tag) {
this.setState(state => {
const newSelectedTags = new Set(state.selectedTags);
if (newSelectedTags.has(tag)) {
newSelectedTags.delete(tag);
} else {
newSelectedTags.add(tag);
}
return {
selectedTags: newSelectedTags
};
});
}
handleClearAll () {
this.setState({
selectedTags: new Set()
});
}
render () {
const addonState = Object.entries(supportedAddons).map(([id, manifest]) => ({
id,
manifest,
state: this.state[id]
}));
const unsupported = Object.entries(unsupportedAddons).map(([id, manifest]) => ({
id,
manifest
}));
return (
{this.state.dirty && (
)}
{!this.state.loading && (
{settingsTranslations.resetAll}
{settingsTranslations.export}
{settingsTranslations.import}
{unsupported.length ? (
) : null}
{this.state.extended ?
// Don't bother translating, pretty much no one will ever see this.
// eslint-disable-next-line max-len
`You have enabled debug mode. (Addons version ${upstreamMeta.commit})` :
`Addons version ${upstreamMeta.commit}`}
)}
);
}
}
AddonSettingsComponent.propTypes = {
onExportSettings: PropTypes.func
};
export default AddonSettingsComponent;