diff --git a/extensions/chromium/runet-censorship-bypass/src/extension-common/pages/options/src/components/App.js b/extensions/chromium/runet-censorship-bypass/src/extension-common/pages/options/src/components/App.js
index e93a82a..8c4463d 100644
--- a/extensions/chromium/runet-censorship-bypass/src/extension-common/pages/options/src/components/App.js
+++ b/extensions/chromium/runet-censorship-bypass/src/extension-common/pages/options/src/components/App.js
@@ -7,6 +7,7 @@ import getNotControlledWarning from './NotControlledWarning';
import getTabPanel from './TabPanel';
import getPacChooser from './PacChooser';
import getNotifications from './Notifications';
+import getExceptions from './Exceptions';
import getFooter from './Footer';
@@ -14,8 +15,11 @@ export default function getApp(theState) {
const NotControlledWarning = getNotControlledWarning(theState);
const TabPanel = getTabPanel(theState);
+
const PacChooser = getPacChooser(theState);
const Notifications = getNotifications(theState);
+ const Exceptions = getExceptions(theState);
+
const Footer = getFooter(theState);
return class App extends Component {
@@ -44,7 +48,7 @@ export default function getApp(theState) {
const warningHtml = warns
.map(
- (w) => w && w.messageHtml || ''
+ (w) => w && w.message || ''
)
.filter( (m) => m )
.map( (m) => '✘ ' + m )
@@ -104,7 +108,7 @@ export default function getApp(theState) {
warns = warns.filter( (w) => w );
if (err || warns.length) {
- showErrors(err, ...warns);
+ this.showErrors(err, ...warns);
} else {
this.setStatusTo(afterStatus);
}
@@ -119,12 +123,13 @@ export default function getApp(theState) {
}
- render(props) {
+ render(originalProps) {
- props = Object.assign({}, props, {
+ const props = Object.assign({}, originalProps, {
funs: {
setStatusTo: this.setStatusTo.bind(this),
conduct: this.conduct.bind(this),
+ showErrors: this.showErrors.bind(this),
},
ifInputsDisabled: this.state.ifInputsDisabled,
});
@@ -139,7 +144,7 @@ export default function getApp(theState) {
},
{
label: 'Исключения',
- content: "Exceptions().render(this.props)",
+ content: createElement(Exceptions, props),
},
{
label: 'Свои прокси',
diff --git a/extensions/chromium/runet-censorship-bypass/src/extension-common/pages/options/src/components/ExcEditor.js b/extensions/chromium/runet-censorship-bypass/src/extension-common/pages/options/src/components/ExcEditor.js
new file mode 100644
index 0000000..99ede5f
--- /dev/null
+++ b/extensions/chromium/runet-censorship-bypass/src/extension-common/pages/options/src/components/ExcEditor.js
@@ -0,0 +1,344 @@
+import Inferno from 'inferno';
+import Component from 'inferno-component';
+import css from 'csjs-inject';
+
+export default function getExcEditor(theState) {
+
+ const scopedCss = css`
+
+ #exc-address-container {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ }
+ #exc-address-container > a {
+ border-bottom: 1px solid transparent;
+ margin-left: 0.2em;
+ align-self: flex-end;
+ }
+ #exc-address {
+ width: 100%;
+ display: flex;
+ align-items: baseline;
+ --exc-hieght: 1.6em;
+ font-size: 1em;
+ border-bottom: 1px solid var(--ribbon-color) !important;
+ }
+ input#exc-editor {
+ border: none;
+ width: 100%;
+ background: inherit;
+ /* The two below align '.' (dot) vertically. */
+ max-height: var(--exc-hieght) !important;
+ min-height: var(--exc-hieght) !important;
+ }
+ #exc-radio {
+ display: flex;
+ justify-content: space-around;
+ margin-top: 0.5em;
+ }
+ [name="if-proxy-this-site"]:checked + label {
+ font-weight: bold;
+ }
+ #exc-address.ifYes {
+ background-color: lightgreen;
+ }
+ #exc-address.ifNo {
+ background-color: pink;
+ }
+
+ `;
+
+ const labelIfProxied = '✔';
+ const labelIfNotProxied = '✘';
+ const labelIfAuto = '↻';
+
+ /* Not used.
+ const sortOptions = (options) => {
+
+ const aWins = 1;
+ return options.sort(([aHost, aState], [bHost, bState]) => aState === undefined ? aWins : aHost.localeCompare(bHost))
+
+ };
+ */
+
+ return class ExcEditor extends Component {
+
+ modsToOpts(pacMods) {
+
+ return Object.keys(pacMods.exceptions || {}).sort().map(
+ (excHost) => [excHost, pacMods.exceptions[excHost]]
+ );
+
+ }
+
+ constructor(props) {
+
+ super(props);
+
+ const pacMods = props.apis.pacKitchen.getPacMods();
+ this.state = {
+ trimmedInputValueOrSpace:
+ props.currentTab && !props.currentTab.url.startsWith('chrome') ? new URL(props.currentTab.url).hostname : '',
+ sortedListOfOptions: this.modsToOpts(pacMods),
+ isHostHidden: {}
+ };
+
+ }
+
+ hideAllOptions() {
+
+ this.setState({
+ isHostHidden: this.state.sortedListOfOptions.reduce(
+ (isHostHidden, [excHost]) => {
+
+ isHostHidden[excHost] = true;
+ return isHostHidden;
+
+ },
+ {}),
+ });
+
+ }
+
+ isHostValid(host) {
+
+ const ValidHostnameRegex = /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/;
+ if(!ValidHostnameRegex.test(host)) {
+ this.props.funs.showErrors(new TypeError('Должно быть только доменное имя, без протокола, порта и пути. Попробуйте ещё раз.'));
+ return false;
+ }
+ return true;
+
+ }
+
+ handleRadioClick(event) {
+
+ const host = this.state.trimmedInputValueOrSpace;
+ (() => { // `return` === `preventDefault`.
+
+ if(!this.isHostValid(host)) {
+ return false;
+ }
+
+ const pacMods = this.props.apis.pacKitchen.getPacMods();
+ pacMods.exceptions = pacMods.exceptions || {};
+
+ let ifYesClicked = false;
+ switch(event.target.id) {
+ case 'this-auto':
+ delete pacMods.exceptions[host];
+ break;
+
+ case 'this-yes':
+ ifYesClicked = true;
+ case 'this-no':
+ if (ifYesClicked && !pacMods.filteredCustomsString) {
+ this.props.funs.showErrors( new TypeError(
+ 'Проксировать СВОИ сайты можно только при наличии СВОИХ прокси (см. «Модификаторы» ). Нет своих прокси, удовлетворяющих вашим требованиям.'
+ ));
+ return false;
+ }
+
+ pacMods.exceptions[host] = ifYesClicked;
+ break;
+
+ default:
+ throw new Error('Only yes/no/auto!');
+ }
+
+ this.props.funs.conduct(
+ 'Применяем исключения...',
+ (cb) => this.props.apis.pacKitchen.keepCookedNowAsync(pacMods, cb),
+ 'Исключения применены. Не забывайте о кэше!',
+ () => this.setState({sortedListOfOptions: this.modsToOpts(pacMods)})
+ );
+
+ })();
+ // Don't check before operation is finished.
+ event.preventDefault();
+
+ }
+
+ handleKeyDown(event) {
+
+ if(event.key === 'Enter') {
+ this.hideAllOptions();
+ }
+ return true;
+
+ }
+
+ handleInputOrClick(event) {
+
+ // Episode 1.
+
+ const ifClick = event && event.type === 'click';
+
+ // If triangle button on right of datalist input clicked.
+ let ifTriangleClicked = false;
+ {
+ const maxIndentFromRightInPx = 15;
+ ifTriangleClicked = ifClick
+ && !this.rawInput.selectionStart && !this.rawInput.selectionEnd
+ && event.x > this.rawInput.getBoundingClientRect().right - maxIndentFromRightInPx;
+ }
+
+ const setInputValue = (newValue) => {
+
+ if (ifClick && !ifTriangleClicked) {
+ // Don't jerk cursor on simple clicks.
+ return;
+ }
+ // See bug in my comment to http://stackoverflow.com/a/32394157/521957
+ // First click on empty input may be still ignored.
+ const newPos = this.rawInput.selectionStart + newValue.length - this.rawInput.value.length;
+ this.rawInput.value = newValue;
+ window.setTimeout(() => this.rawInput.setSelectionRange(newPos, newPos), 0);
+
+ };
+
+ const trimmedInput = event.target.value.trim();
+ const ifInputEmpty = !trimmedInput;
+ const ifInit = !event;
+ const currentHost = ifTriangleClicked ? '' : (trimmedInput || (ifInit ? '' : ' '));
+ setInputValue(currentHost);
+ this.setState({trimmedInputValueOrSpace: currentHost});
+
+ // Episode 2.
+
+ let exactHost, exactState; // Undefined.
+ let editedHost = false;
+ const hidden = this.state.sortedListOfOptions.reduce(
+ (hiddenAcc, [excHost, excState]) => {
+
+ if (excState === undefined) {
+ editedHost = excHost;
+ } else if (excHost === trimmedInput) {
+ // Exact match found for input.
+ [exactHost, exactState] = [excHost, excState];
+ }
+ hiddenAcc[excHost] = false;
+ return hiddenAcc;
+
+ },
+ {}
+ );
+ let options = this.state.sortedListOfOptions;
+ const removeEditedHost = () => {
+
+ options = options.filter(([excHost, excState]) => editedHost !== excHost);
+ delete hidden[editedHost];
+
+ };
+
+
+ (() => {// `return` === setState
+
+ if (ifTriangleClicked || ifInputEmpty) {
+ // Show all opts.
+ if (editedHost) {
+ // Example of editedOpt.value: 'abcde ' <- Mind the space (see unhideOptAndAddSpace)!
+ const ifBackspacedOneChar = ifInputEmpty && editedHost.length < 3;
+ if (ifBackspacedOneChar) {
+ removeEditedHost();
+ }
+ //
+ }
+ return true;
+ }
+
+ if (editedHost) {
+ const ifUpdateNeeded = editedHost !== trimmedInput;
+ if(!ifUpdateNeeded) {
+ hidden[editedHost] = true;
+ return true;
+ }
+ // Not exact! Update!
+ removeEditedHost();
+ }
+
+ if (!exactHost) {
+ editedHost = trimmedInput;
+ options.unshift([editedHost, undefined]);
+ if (!ifClick) {
+ // New value was typed -- don't show tooltip.
+ hidden[editedHost] = true;
+ }
+ }
+
+ // Exact found!
+ // Do nothing.
+
+ })();
+
+ this.setState({
+ isHostHidden: hidden,
+ sortedListOfOptions: options,
+ });
+
+ }
+
+ render(props) {
+
+ const inputProxyingState = this.state.sortedListOfOptions.reduce((acc, [excHost, excState]) => {
+
+ if ( acc !== undefined ) {
+ return acc;
+ }
+ return this.state.trimmedInputValueOrSpace === excHost ? excState : undefined;
+
+ }, undefined);
+
+ const onradio = this.handleRadioClick.bind(this);
+ const oninput = this.handleInputOrClick.bind(this);
+
+ return (
+
+
+